返回所有文章
發佈於 · 作者 Renaud Deraison

那個 commit 是一條分支

Air 在 9 月 17 日揭露了 Plugin4Shell:Claude Code、Codex、Gemini CLI 與 Copilot 的外掛市集,都把每個外掛釘在一個四十字元的 commit 雜湊上,而代理程式會把那個雜湊簽出,卻從不確認自己拿到的就是它。攻擊者只要把一條分支取成被釘住的雜湊那個名字,就能贏得簽出,剩下的由背景更新程式完成,沒有提示,也沒有點擊。Anthropic 與 OpenAI 已修補。Google 表明不會修,Microsoft 至今沒有動作。在 Bromure Agentic Coding 工作區裡,贏得簽出的程式碼落進的是一台只有誘餌憑證的 VM,外面還有由主機端強制執行的推送政策與出口規則。

每一份供應鏈指南都告訴你,把相依套件釘在雜湊上,因為版本號只是一張別人搬得動的 標籤,而雜湊就是內容本身。四個編碼代理程式聽從了這個建議。沒有一個去確認,拿到的 雜湊是不是它要的那一個。

一個外掛通過了審查。你讀過它,或者你的平台團隊讀過它,於是它釘在某個 commit 上進了 核可清單:四十個十六進位字元,只能描述唯一一棵樹,因為改動樹就會改動字元。這正是它 的吸引力。任何握有適當權限的人都能搬動一個標籤或重新發佈一個版本,但 commit 雜湊 本應是一項事實。

9 月 17 日,Air 的三位研究員(Or Nevo、Dor Granat 與 Niv Hoffman)發表了 Plugin4Shell。他們的結論是:釘選 的價值不會高於解析它的那一段程式,而四個主要編碼代理程式的解析,並不檢查自己的 成果。 The Register 當天就做了報導。 Claude Code、OpenAI 的 Codex、Google 的 Gemini CLI 與 Microsoft 的 Copilot 全都 受影響。

git 偏好 ref

commit 雜湊是內容,同時也是一個名字。分支與標籤也是,而 git 用同一套查找把它們 全都解析出來。你交給 git 一個既是分支名又是物件 id 的字串,它就得挑一個;它挑分支。 研究員用一句話講完:「當一個名字同時是有效的 ref 和物件 id 時,git 偏好 ref。」

git 確實會警告。它會為那個參照印出一則歧義提示,印進一個你並沒有在看的終端機, 因為執行簽出的是一支背景更新程式。

git 的分支名稱接受四十個十六進位字元。控制外掛儲存庫的攻擊者,建立一條與市集釘選 同名的分支,並把它設為預設分支。克隆時,這條分支會跟著同名的 commit 一起下來。接著 代理程式執行它的簽出:

git clone <plugin repo> ./
git checkout aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

簽出落在分支上。Gemini CLI 走另一條路到同一個地方:它用 --ref 取得正確的 commit, 然後執行 git checkout FETCH_HEAD,而只要儲存庫裡有一條叫 FETCH_HEAD 的分支, 這行就會解析到那條分支。

一個名字,兩種可能的意思市集的釘選aaaaaaaa…aaaa已審查、已記錄、未改動儲存庫裡的物件 idaaaaaaaa…aaaa通過審查的那份程式碼同樣四十字元的分支refs/heads/aaaaaaaa…aaaa攻擊者推上去的東西git checkout aaaa…aaaa解析到分支警告印出來了,沒人在看代理程式從不追問自己實際簽出了什麼。一道指令就能做到:git rev-parse HEAD。
一個字串,兩種意思。commit aaa…aaa 和一條叫 aaa…aaa 的分支,兩者都存在於克隆下來的儲存庫裡。git checkout 必須挑一個,而它挑了 ref。市集記錄的釘選仍然一模一樣,磁碟上的那棵樹卻屬於別人。

五個步驟,最後一個沒有點擊

研究員把攻擊拆成五個動作,而前三個都是一個運作良好的市集刻意在做的事。

埋下。 發佈一個良性外掛。它在 commit aaa…aaa 通過審查,因為它確實沒有問題。

採用。 開發者安裝它,釘在已審查的 commit 上。這一步,你做的正是指南要你做的事。

版本更新。 市集把釘選換到較新的 commit bbb…bbb,同樣良性,同樣已審查。

抽地毯。 攻擊者建立一條叫 bbb…bbb 的分支,設為預設,並讓它指向惡意程式碼。 他什麼都沒有重新發佈,市集的記錄也沒有移動。你會去核對的那四十個字元,就是你先前 核對過的那四十個字元。

從自動更新到遠端程式碼執行。 Claude Code 與 Codex 預設在背景更新已安裝的外掛。 下一次排定的更新就會簽出那條分支。Air 把這一步形容為「沒有提示,沒有點擊」。

Plugin4Shell,一步一步來誰在行動1一個良性外掛發佈,並在 commit aaa…aaa 通過審查此時程式碼確實沒有任何問題市集2開發者安裝它,釘在已審查的 commit 上這是被推薦的做法,而且做對了3市集把釘選換到較新的 commit,bbb…bbb同樣良性,同樣已審查市集4一條叫 bbb…bbb 的分支被建立並設為預設沒有任何東西被重新發佈;記錄的釘選沒有移動攻擊者5背景更新程式簽出那條分支程式碼在開發者的機器上執行 — 沒有提示,沒有點擊代理程式
步驟一到三是市集照設計運作,而步驟二是開發者遵循標準建議。攻擊是步驟四和五,而這兩步都不需要開發者批准任何事。

還有第二條門路,可以跳過埋下這一步:接管一位正當外掛作者的儲存庫,把惡意版本推給 每一個已經裝了該外掛的代理程式。分支的把戲一模一樣,而且沒有審查需要通過。

Air 把結果描述為「在員工機器上的完整遠端程式碼執行」,以該員工的權限執行。攻擊者 不需要提權。程式碼以筆電主人的身分執行,觸及他們的檔案與他們的正式環境存取權。

這已經是這幾位研究員第三次數到同一個形狀。他們先前關於惡意代理技能的研究,描述過 一個觸及超過 26,000 個代理程式的技能,以及一場劫持行動,拿下了已在 134,000 個代理 程式上使用的 925 個技能。那些行動需要你安裝壞東西; 這一次需要你安裝好東西,然後等著。

四家裡有兩家沒有修補

Air 在 6 月通報了四家廠商。

Anthropic 在 6 月 17 日修補了 Claude Code,版本 2.1.179。OpenAI 的修正隨 Codex 0.146.0 出貨,並在 8 月 12 日獲得驗證。Google 在 8 月 4 日回覆,Gemini CLI 已經 淘汰,不會修補。Google 要使用者改用 Antigravity,所以一份留在磁碟上的 Gemini CLI 就一直留著漏洞。Microsoft 沒有為 Copilot 出任何東西。GitHub 表示自家平台會擋下 看起來像 commit SHA 的分支與標籤名稱,這涵蓋了託管在 GitHub 上的外掛,對託管在別處 的外掛儲存庫則毫無作用,Bitbucket 也一樣。The Register 引用 Microsoft 自己的數字 指出,財星五百大企業中有將近 90% 在用 Copilot。

修正本身只有一行,而且必須跑在代理程式內部:

test "$(git rev-parse HEAD)" = "<pinned-sha>" || abort

解析出落進工作目錄的那個 commit,除非相符否則拒絕繼續。沒有任何市集能代你做這件 事,因為在市集把話說完之後,是你的代理程式在你的機器上解析那個釘選。

對今天讀到這篇文章的大量開發者來說,「更新你的代理程式」不是一個可用的答案。那就 剩下另一個問題:當那段程式碼贏得簽出並開始執行,它會找到什麼?

在一個 Bromure Agentic Coding 工作區裡

Air 那句話,以員工的權限執行,說得很準。工作區存在的意義,就是讓它不成立。

Bromure Agentic Coding 裡,代理程式跑在你 Mac 上一台硬體 虛擬化的 Linux VM 中,而這台 VM 從來不持有真正的憑證。工作階段啟動時,應用程式會把 保留結構的誘餌寫進代理程式執行的環境:ANTHROPIC_API_KEYGH_TOKENLINEAR_API_KEY 等等作為環境變數,並把相對應的假值寫進 ~/.git-credentials~/.docker/config.json~/.kube/config~/.aws/config 以及代理程式的 MCP 設定 檔。每個誘餌都保留工具預期的形狀,所以工具會接受它:GitHub 誘餌是 ghp_ 加 36 個 字元,Anthropic 的以 sk-ant-api03-brm- 開頭,Kubernetes 的 bearer token 以 brm-k8s- 開頭。Bromure 以每次安裝專屬的鹽,透過 HKDF-SHA256 從真實值推導出每一個 誘餌,因此它跨工作階段穩定不變,而對撿走它的人毫無價值。

真實的值加密存放在主機上。主機端的代理伺服器在請求離開 VM 之後,才在線路上把它們 換回去,而且只在目的地符合該憑證所發給的主機時才換。一個外掛掃過家目錄尋找設定 檔,這正是這類酬載第一件會做的事,它撿到的只是一組格式完好的誘餌。

簽出之後:同樣三個動作,做兩次一台一般的開發者機器讀取家目錄sk-ant-api03-… ghp_… AKIA…真金鑰、真權杖、真 kubeconfig成功推送到它構得著的儲存庫git push → git-receive-pack開發者自己的 git 憑證成功把蒐集到的東西送到某處POST https://attacker.example普通的對外 HTTPS,路上什麼也沒有成功一個 Bromure Agentic Coding 工作區讀取家目錄sk-ant-api03-brm-… ghp_… brm-k8s-…誘餌;真的在主機上誘餌推送到它構得著的儲存庫git-receive-pack = 寫入擋下,或跳出主機對話框讓你決定政策把蒐集到的東西送到某處451 — 一個位元組都沒轉發出口規則,接著 VM 被暫停停住
同一次簽出,同一個惡意外掛,兩台機器。在一般筆電上,程式碼繼承了開發者真正的憑證,開發者能到的地方它都能到。在工作區裡,它讀到的憑證是誘餌,它嘗試的推送撞上主機端的寫入政策,而它對外的呼叫撞上一條來賓改不了的出口規則。

推送必須在主機上獲得批准

一個被入侵的外掛在開發者機器上能做的最有價值的事,就是推送。 一台筆電變成一百個儲存庫,正是這樣發生的。

Guardrails 會把代理程式發往 GitHub 的每一次呼叫分類。走 HTTPS 的 git push 到達 代理伺服器時是 git-receive-pack,算作寫入。唯讀模式拒絕它。寫入前先詢問 模式會在你的 Mac 上跳出一個對話框,標題是 Allow write on "<scope>" from workspace "<name>"?,一字不改地顯示該操作,而不是 它的摘要。git fetch 算作讀取,不打擾你就通過。

這個分類跑在主機的代理伺服器裡,在 VM 之外。來賓內部的程式碼既關不掉它也繞不過去, 因為它不是跑在做決定的那台機器上。

對外的呼叫撞上一條來賓改不了的規則

每個工作區都有一道出口防火牆:一份有序的、pf 風格的允許與拒絕規則清單,涵蓋主機、 IP 範圍、通訊協定、連接埠,而對網頁流量還涵蓋個別的 HTTP 動詞。 allow web api.example.com GET,POST 讓代理程式只能查詢、不能變更,就在線路上生效, 無論 VM 裡哪個工具發出請求都一樣。

有兩層在執行它。虛擬網路交換器依目的地 IP 和從 DNS 側錄到的主機名,對每一條流套用 規則,除了 HTTPS 也涵蓋純 TCP 與 UDP。代理伺服器再依 TLS 伺服器名稱與 HTTP 方法 套用一次。Bromure 不需要來賓配合,就把 VM 的 80 和 443 埠流量導進代理伺服器,所以 裡面沒有任何東西能豁免於檢查。你的修改會立刻送達進行中的工作階段,這在你讀到像這樣 一則揭露的當天特別重要。

底下還有失陷偵測器,預設開啟,沒有東西要設定。代理伺服器會在單次掃描中,檢查每個 對外請求裡有沒有為該工作區鑄造的誘餌權杖。一個誘餌被寄往它從未被鑄造去對應的主機, 就是外洩的特徵,代理伺服器會以 HTTP 451 回應,一個位元組也不轉發。它當場暫停 VM, 並發出警示,讓你選擇關閉工作區、匯出磁碟與家目錄以供調查,或是自負風險繼續。 Bromure 會把該工作區標記為已失陷,下一次啟動會清除 VM 磁碟與家目錄,同時保留你的 權杖、SSH 金鑰與工作區設定。走掉的只有誘餌,所以沒有什麼需要輪替。

零點擊意味著沒有東西可供察覺

步驟五最銳利的性質是它的安靜。背景的外掛更新不會產生提示,也不會產生工具呼叫, 所以代理程式自己的介面,沒能給開發者任何可以抓住的東西。Air 的論點是,這個攻擊跑在 系統中你沒有理由去盯著的那一塊。

工作區把盯著這件事搬到別處。安全引擎把每一個決定寫成你 Mac 上 Security Timeline 視窗裡的一列:防火牆判決(evil.example:443 tcp — blocked)、Guardrails 的執行 (DELETE api.github.com/repos/… — blocked)、credential brokering,包括紅色的 blocked — exfiltration attempt, VM paused 那一列,外加供應鏈判決與提示注入命中, 都有顏色標示、可以篩選。一個工作階段還可以錄下已執行指令與已連到主機的加密軌跡。

一個沒告訴你就自己更新的外掛,終究還是得發出請求,而每一個請求都要越過一道邊界, 那道邊界在 hypervisor 的另一側留著自己的記錄。

先確認你的代理程式版本

Claude Code 2.1.179 與 Codex 0.146.0 含有這項修正。如果你用 Gemini CLI,不會有 修正。如果你用 Copilot,目前還沒有。

能關的地方就關掉外掛自動更新

零點擊這個性質來自背景更新程式。手動更新會把人重新放回步驟五的迴圈裡。

在 CI 裡自己驗證簽出

在釘選的簽出之後執行 git rev-parse HEAD,拿來和釘選比對,就是廠商不得不補上的 那一行檢查。凡是你用 SHA 克隆的地方,它都管用。

假設有人會贏得簽出

釘選、審查、市集審核,在這裡全都守住了,攻擊還是打進來。接下來的結果,就取決於 贏得簽出的那段程式碼構得著什麼。

Plugin4Shell 用同樣的條件抓住謹慎的開發者和粗心的開發者。裝了當紅熱門那位,下場並 不比讀了原始碼、釘住雜湊、把釘選記下來的那位更糟。內容定址本該保護第二位開發者, 它卻沒有做到,因為 git 是透過一種查找來解析這個位址,而那種查找也把同樣的字元拿去 指別的東西。

繼續釘選。然後假設:一個建立在查找或名稱比對之上的控制,總有一天會在你睡著的時候 被交上錯誤的答案。為那個早晨做設計:讓代理程式跑在一個沒有任何值得拿走的金鑰的 地方,並把它做過什麼的記錄,留在程式碼構不著的邊界另一側。