FR-187 D1 掃描報告:Agent 新版的下載與驗章

§1

🔴 一句話結論

不急,沒有會讓惡意 Agent 包混進來的洞。 只找到 1 個低風險問題(F1):任何一台已註冊的 Agent 按「檢查更新」時,主系統會把「上一次是誰按檢查」連同全機最舊的 Agent 版號一起回給它,所以別的客戶的管理員帳號名稱,或別家 Agent 的編號,會被看到。三個獨立檢查員投票 3 比 0 判定成立,我也自己開檔核對過。下載鏈本身(驗章、核 sha256、限定下載主機)照卡片「重點看什麼」逐條人工查過,都守得住。

§2

這一棒在檢查什麼

主系統替 Agent 去原廠更新站查新版、下載 Agent 包,再推給各台 Agent。這一棒看下載這一段,有五步:

  1. 問更新站有沒有新版;
  2. 拿下載網址;
  3. 用本機存的原廠印章,驗發布清單的簽章;
  4. 核對下載檔的 sha256 指紋和大小;
  5. 落檔。

主系統自己的更新包也走同一支驗章程式(package_verifier.py)。

範圍 4 檔,約 1096 行。為了確認 F1 是從哪條路打得到,翻了範圍外的 core/plugins/_agent_console_update.py 與 config/config.py,只為查證,不另列發現。

§3

掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
F1 Agent 按「檢查更新」拿回的結果,帶著「上次是誰按的」和全機最舊的 Agent 版號 別的客戶看得到總部管理員的登入帳號名稱(可以拿來猜密碼)、另一家客戶 Agent 的編號,以及全機最舊的 Agent 版號(可以挑舊版的洞打) ① 手上有一台已註冊、還在用的 Agent ② 多個客戶共用一台主系統而且彼此不互信(SaaS,或總部底下的子公司) ③ 5 分鐘內剛好有別人按過檢查 app/product_update/service/agent_release_service.py:488(public_check);入口 core/plugins/_agent_console_update.py:56-58 給 Agent 的回應另外做一份「只放這幾欄」的白名單(status、latest、compatible、reason、reason_text、at),by、agent_base、host_version、error 一律不給 低

為什麼是低不是中: 漏出去的只是帳號名稱和版號,沒有密碼、金鑰,也改不了任何東西。而且要有多個不互信的客戶共用一台主系統才有意義,第一版的落地版通常只有一家客戶。

§4

每條發現的詳述

F1:Agent 檢查更新時,看得到別人的帳號名稱與全機版號

白話: 主系統的「上一次 Agent 檢查結果」整台機器只存一份,畫面跟各台 Agent 共用。這份紀錄裡有 by(誰按的)、agent_base(全機最舊的 Agent 版號)、host_version、error。拿給 Agent 時用的是 public_check,它只拿掉 signed_manifest(簽章清單原文)這一欄,其他欄位全部照給。這種「黑名單」寫法(只列出不准給的,其餘照給)會漏東西。

出事會怎樣: B 客戶的 Agent 呼叫 POST /agents/update/check。如果 5 分鐘內有總部管理員在更新中心按過檢查,回應的 last_check.by 就是那位管理員的登入帳號;如果是 A 客戶的 Agent 主控台按的,看到的就是 agent-console:<A 客戶的 Agent 編號>。

要先有什麼才打得到:

  • 有一張有效的 Agent 通行證,加上 DPoP 金鑰(每台 Agent 自己的簽名金鑰,用來證明「這個請求真的是這台發的」);
  • 多個客戶共用一台主系統,而且彼此不互信;
  • 漏的內容取決於「上一次是誰按的」。

在哪裡:

  • app/product_update/service/agent_release_service.py:488:out = {k: v for k, v in record.items() if k != "signed_manifest"};
  • app/product_update/service/agent_release_service.py:197:紀錄寫入 by、host_version、agent_base;
  • core/plugins/_agent_console_update.py:56-58:check_for_agent 把 public_last_check() 或 check() 的結果原樣回給 Agent。

怎麼修: 在 check_for_agent 回傳前,用白名單只挑 Agent 需要的欄位(status、latest、compatible、reason、reason_text、at)。也可以把 public_check 本身改成白名單,by 只在管理員總覽另外加上。

runner 開檔核對: 成立。 上面三個位置都開過,跟描述一致。

§5

「重點看什麼」逐條人工查證

  1. package_verifier.py:116-170:印章編號、印樣目錄、清單型別、預覽版比版號

    • 印章編號(kid)只接受 16 位小寫十六進位(_KID_RE,:34、:118),所以 ../ 這類路徑擋得掉;印樣檔排除捷徑檔(symlink),大小上限 4KB;只從本機印樣目錄讀。
    • 驗章之後還比對 payload_type(清單型別)和 kid(:166-168),拿主系統的清單頂替 Agent 清單會被拒,反過來也一樣。
    • compare_versions(:98-110)照 semver 比,同版號時預覽版排在正式版前面,所以 1.2.0-beta 會被當成比 1.2.0 舊,拿它來降版會被擋。降版這件事本來就要原廠私鑰簽過的清單才做得到。
    • 結論:不成立。
  2. agent_release_service.py:127-146 bundle() 只比大小、不重算 sha256

    • 推給 Agent 時帶的 sha256 是 manifest.sha256(:146),來自驗過章的清單,不是讀本機檔重算的。
    • 落地之後就算有人把檔換成一樣大小的,Agent 下載完會拿簽過的 sha256 去核,核不過就不裝(docstring :130 這樣寫;Agent 那一側不在範圍內,沒有開檔核對)。
    • 要換那個檔,得先能寫主系統的資料目錄,那等於已經拿下主機了。
    • 結論:不成立(前提是 Agent 那一側真的有核,建議 Agent 掃描卡順便確認)。
  3. :243-260 download:續傳的 .part 檔被預先放一份

    • package_downloader.download 從 .part 現有的大小接著下載(:220)。下載完 install_verified 會對整個檔案核大小和 sha256,核過才改名成正式檔(:242)。
    • 預先放進去的內容會讓最後的 sha256 對不上,結果是下載失敗,不會放行。核 sha256 在落檔之前。
    • 能預先放檔的人,本來就有主機的寫入權限。
    • 結論:不成立。
  4. update_site_client.py:59-80:限 https、不跟轉址、下載主機白名單、_dev_loopback

    • _post 帶 follow_redirects=False(:79),所以更新站叫它轉去別的網址也不會跟。
    • 下載網址要精確等於 DOWNLOAD_HOSTS(:28、:153),網址裡帶帳號密碼也拒收(:61)。
    • 注意一點: ENV_LABEL 取自環境變數 ENV,而預設值就是 DEVELOP_PREMISE(config/config.py:95),被 _DEV_ENV_LABELS 當成開發機。所以正式機的 ENV 如果沒設,就會放寬成「可以用 http 連本機(127.0.0.1/localhost/::1)」。
    • 影響有限:只放寬「本機回環位址」,外面的主機照樣要 https。下載網址照樣要在白名單裡。清單照樣要驗章,包照樣要核 sha256,偽造的包還是裝不進去。
    • 要打得到,攻擊者得在主系統主機上起一個假的更新站,還得把 UPDATE_SITE_URL 指過去,這兩件事都要先拿下主機。
    • 我沒找到出貨範本裡寫 ENV= 的地方(只有 .env.sample 有);正式安裝 stack 實際設什麼,這一棒沒有查證。
    • 結論:本身不成立,但「預設值等於開發值」是出錯就預設放行的寫法,建議首腦判斷要不要開一張小卡,把預設改成正式值。
  5. same_version.py:PRODUCT_UPDATE_ALLOW_SAME_VERSION=1

    • 這個開關只放寬「同版本可以重裝」(package_verifier.py:224 的 allow_same)。簽章、清單型別、主版號、sha256 照樣全驗,降版和跨主版號照樣擋。
    • 本機清單要排除捷徑檔(same_version.py:41)。
    • 客戶自己加了這個開關,最多就是把同一個原廠版本再裝一次。主機端 upgrade_order.sh 也要另外開同名開關才會真的重裝。
    • 結論:不成立。
  6. 送給更新站的身分(授權照全文、機器指紋)有沒有進 log

    • update_site_client.py 的 log 只記路徑、HTTP 狀態碼、錯誤種類,還有「沒有身分票」這句提醒(:113)。授權照全文、指紋、身分票都沒有寫進 log。
    • 連線失敗只記例外的類別名(type(exc).__name__,:82),不會帶出網址或請求內容。
    • 結論:不成立。
§6

這份結果可信到什麼程度

  • 「這 1 條存在嗎?」 可信度高。三個獨立檢查員分別從三個角度看(打不打得到、出事多大、有沒有別的防線),3 比 0 判定成立,我也自己開檔核對過。
  • 「只有這 1 條嗎?」 不能保證。這次是低強度掃描:一位研究員讀全範圍,另一位專查有沒有寫死的密碼。沒有跑測試、沒有實際打過,結論全部來自讀程式碼。「重點看什麼」六項我逐條人工補查過,只多找到第 4 項「ENV 預設是開發值」這個加固建議,本身不算洞。
§7

執行概況

項目 值
run ID wf_08cfcc94-7fa
掃描版本 eee99445e72a74662da483920d61ecfad81209ad(工作目錄乾淨)
強度與範圍 low,4 檔
verification.status verified(reason_kind 為 null)
candidates(候選問題) 1(去重後 1)
panel_votes(檢查員投票) 3:三個檢查員對 1 條各投一票,全部投出
researchers_dispatched / returned(派出/回來的研究員) 2 / 2(一位讀全範圍、一位專查寫死的密碼)
耗時 約 37 分鐘,5 個 agent,約 53 萬 token
原始產物 docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-180451/