FR-177.A3:Agent「線上更新鏈」資安掃描報告

⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。

1. 🔴 一句話結論

工具沒找到問題,我人工查了七個重點也沒找到會被打穿的洞。 更新包要裝上去,得先過三道鎖,而且都是主機上信得過的那一側在鎖:比對主系統給的指紋、用「現在已裝那一版」的程式驗新版的簽章、禁止裝回舊版。另外記下兩個小觀察,都不急,見第 4 節。

2. 這一棒在檢查什麼

客戶機上的 Agent 收到主系統「更新到某一版」的命令後,會先下載更新包、確認包沒被調換,再交給主機上的安裝腳本安裝,最後回報結果。主控台上也有「請主系統更新」和「離線上傳更新包」兩個按鈕。這條鏈如果被騙過,等於能在客戶的每一台 Agent 上執行任意程式,所以是價值最高的攻擊面。範圍是五支檔,約 1,235 行:

  • guidant_agent/sync/app/update_sync.py:下載、驗證、寫「升級單」
  • guidant_agent/sync/app/update_events.py:回報進度與結果
  • guidant_agent/sync/app/update_log_upload.py:上傳主機的升級紀錄
  • guidant_agent/console/app/update_console.py:離線上傳包的檢查
  • guidant_agent/console/api/update_routes.py:主控台更新頁的網址入口

3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 急不急
— 工具:沒有發現 — — — —
觀察 1 主系統直推的更新命令裡,指紋沒有另外附簽章 單靠這點裝不上惡意包:主機還會再驗一次新版簽章 要先拿到主系統的通行證,還要偽造原廠簽章 update_sync.py:118-127 不急,是設計上的取捨
觀察 2 結果回報只看主機寫的進度檔 能改主機檔的人可以讓主系統以為「升級成功」 已經拿到主機的 root(最高權限)或容器內的寫入權 update_sync.py:151-180 不急:到這個程度,對方本來就能做更多事

(觀察不算正式發現,只是記錄。需要時另開卡處理。)

4. 每條「重點看什麼」的人工查證

① 主系統送來的指紋(sha256,就是檔案的「數位指紋」)有沒有簽章? 人工查過,不成立(沒有打得穿的洞),但有一點值得記下(觀察 1)。update_sync.py:118-127 的 _parse_update 只檢查格式,指紋本身沒有另外簽章。資料面的入口要過 JWT(主系統簽發的短效通行證),驗簽、綁這台機器、而且一張只能用一次。真正擋住惡意包的是主機端:scripts/install/lib/upgrade_order.sh:406-411 會用現在已經裝好的那一版的程式(verify-tree)去驗新映像的原廠簽章,再比對簽章裡的版號(:412)。所以就算有人拿到通行證、配上自己的包和指紋,也會在驗章那一步被擋下。 怎麼修(選做):如果要多一層保險,可以讓主系統把 bundle_sha256 包進 Ed25519 信封(原廠簽名的格式)再送,Agent 端用離線上傳已經在用的 verify_payload 驗。這是加深防線,不是補洞。

② 下載完才驗,還是先改成正式檔名再驗? 人工查過,不成立。update_sync.py:588-596 下載時寫進 .part 暫存檔,驗完大小和指紋後才 os.replace 成正式檔名,驗失敗就刪掉。中斷後續傳的話,續傳接起來之後還是會對整個檔重算一次指紋,不會只驗後半段。主機 upgrade_order.sh:344 移走包之後又驗一次指紋。

③ 能不能降級(把有洞的舊版裝回去)? 人工查過,不成立。Agent 端擋同版(update_sync.py:375),但沒擋更舊的版本。不過主機端的 upgrade_order.sh:270-275 一律擋降版:目標不比現行版新就拒絕。只有在主機 .env 開了 AGENT_UPDATE_ALLOW_SAME_VERSION=1 時,才放行「同版」重裝。離線上傳另外有 is_newer 擋(update_console.py:205)。

④ 更新包從哪裡下載? 人工查過,不成立。網址寫死在程式裡(update_sync.py:46 BUNDLE_PATH),命令裡沒有網址欄位。連線走 cloud_channel,底層的 cloud_http 一律用 cloud_tls_verify(連線時會檢查對方真的是主系統),而主系統的位址只收 https。

⑤ 主機與容器的交界:容器內能寫的升級單,主機用 root(最高權限)讀了會怎樣?把 bundle 欄位改成指到別的檔呢? 人工查過,不成立。主機 upgrade_order.sh:175、267 會檢查 job_id 的格式,並要求 bundle 必須完全等於 guidant-agent-compose-<版號>.tar.gz,指向別的路徑會被拒絕。包移走時也確認它不是捷徑檔(! -L)。主機不會直接執行包裡的程式,會先驗章(見①),還會比對包裡的 install.sh 和 docker-compose.yml 跟簽章內的版本完全相同(:413-415)。

⑥ 主控台觸發更新要什麼權限?能不能指定版本和指紋? 人工查過,不成立。四支 route 都掛 @require_session("update")(update_routes.py:55、62、70、115)。「請主系統更新」只帶操作者名字,不能指定版本或指紋(update_sync.py:672),由主系統決定。離線上傳則必須通過原廠簽章驗證(update_console.py:174-187,verify_payload),包的名稱也用簽章裡的版號組出來,不信包內的檔名;解壓走共用的安全解壓 archive_safety。上傳的 log 是主機打包的升級紀錄,Agent 端只做大小(10MB)、格式和「不是捷徑檔」的檢查(update_log_upload.py:30-51),內容由主機腳本決定,不在這次範圍內。

⑦ 回報結果能不能被偽造成「已更新成功」? 人工查過,有條件成立,但不算洞(觀察 2)。update_sync.py:151-180 的 _order_outcome 看到進度檔的 job_id 對得上、status 是 succeeded,就回報成功。進度檔在容器內是唯讀的,由主機的 root 寫入。要偽造,得先有主機的 root,或者已經控制容器內這支程式。到那一步,攻擊者早就能做更大的事。另外還有一條路:如果「現在跑的版本等於目標版本」,也會直接算成功(:174-176),這個版本號來自正在執行的程式本身。

5. 這份結果可信到什麼程度

  • 「這些存在嗎」:工具沒有提出任何發現,所以沒有東西需要確認。上面兩個觀察是我開檔確認過的,行號都已核對。
  • 「只有這些嗎」:不能保證。這次用的是 low 檔,只有一位研究員掃整個範圍,研究員沒提出候選,所以三個獨立的檢查員沒有東西可投票。stamp 上寫「verified」,意思是「沒有東西可驗」,不是「有人確認過很乾淨」。另外,主機端腳本 upgrade_order.sh 不在掃描範圍內,第 4 節只是為了查證才去讀,沒有做完整審查。建議之後另開一棒,專門掃主機端的升級腳本。

6. 執行概況

項目 值
workflow run ID wf_702592dc-a0a
掃描版本 bab2aa31f00d3bf9b2e34c2a6dab88d1885b227f(工作區乾淨)
effort/focus low/attack-surface(只看攻擊者碰得到的正式程式)
verification.status verified
reason_kind null
candidates(候選數) 0
panel_votes(三位檢查員投票數) 0(沒有候選可投)
researchers_dispatched/returned 2/2(一位全範圍,一位專找寫死的密碼)
耗時 約 10 分鐘
歸檔 runs/CLAUDE-SECURITY-20261105-152759/