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

十一項測試通過

Straiker 公開了一個儲存庫,不需越獄就能讓 Claude Code 執行惡意程式。它的 CONTRIBUTING.md 把審查交給儲存庫自己定義的子代理,模型固定為 Haiku,範圍刻意避開測試資料夾。接著 Fable 執行 pytest,一個測試檔下載並啟動 Sliver 植入程式,終端機回報 11 passed。Anthropic 將這份回報以 Informative 結案:信任儲存庫的設定,屬於使用者的工作區信任決定。Bromure Agentic Coding 決定的是這份信任能觸及什麼:一台只握有替身金鑰的虛擬機器、Mac 上的防火牆,以及儲存庫無法竄改的追蹤紀錄。

代理一步不漏地遵循了專案文件中的工作流程,而這套流程是攻擊者寫的。終端機顯示 11 passed。

你在 GitHub 上找到一個小小的指標用戶端,能幫你省下一週的工夫,於是請 Claude Code 把它複製下來看看。它做得很好:讀了程式碼,指出一個沒有上限的緩衝區,以及一個遇到有毒訊息就會永遠打轉的重試迴圈,並提出修正。你照例交代:把這些修好,準備一個 PR。

幾分鐘後,修正完成,測試全綠,摘要寫著 11 passed。就在這幾分鐘裡的某個時刻,一個植入程式在你的筆電上開啟了遠端 shell。

這是 Straiker STAR Labs 的 Brian Cumi 建構的情境,於 9 月 22 日以 Fable to Haiku: How a Malicious Repo Tricked Claude Code Into Running Malware 為題發表。Adversa 的 10 月程式碼代理安全研究彙整 將它列為本月的攻擊手法之一。這個儲存庫沒有利用任何漏洞。每一步都只是照設計者的本意,使用一項有文件記載的功能。

儲存庫如何審查自己

這個儲存庫 northstar-metrics 看起來是一個普通的開源專案,它的第一招也很平常:一份 CONTRIBUTING.md。當你要求一個 PR,你就把代理綁在專案的貢獻流程上,而一個會遵循指示的代理會去讀貢獻指南。這份指南說,審查者在執行測試之前,應先執行專案的初審代理。

那個代理就在儲存庫裡,位於 .claude/agents/first-pass.md。Claude Code 允許專案在這個資料夾中定義自己的子代理,並在 frontmatter 中指定模型與範圍。這一個設定了 model: haiku,並把審查限制在 src/ 與 docs/。它的指示還補上一句:「在主工作階段中閱讀,不能取代執行這個代理」,讓主代理不會跳過它。

完整的鏈條如下:

  1. 主模型 Fable 把審查交給儲存庫的子代理。
  2. 子代理在 Haiku 4.5 上執行,只看 src/ 與 docs/。
  3. 它帶回兩項作者特地埋給它找的發現:一個寫死的版本字串,以及文件與程式碼之間的不一致。兩者都無害。
  4. 文件中的流程把審查視為完成。沒有人打開 tests/test_compat.py。
  5. Fable 執行 pytest。
  6. 在斷言之前,test_compat.py 以十六進位解碼出一個 URL,下載一個 Python 檔,並用 subprocess 啟動它。這段程式包在 try/except: pass 裡,斷言永遠成立。
  7. 一個 Sliver 植入程式開啟指揮控制連線。pytest 印出 11 passed。

Cumi 對這為何奏效的總結是:這個儲存庫倚賴三個平常的假設。專案可以定義自己的子代理,代理會遵循專案文件中的流程,而一次全綠的測試就算是檢查過程式碼。「一個理解這三個假設的惡意儲存庫,不需要打破其中任何一個。它只需要待在三者交會的縫隙裡。」

Straiker 在 9 月 14 日透過 HackerOne 回報給 Anthropic。Anthropic 以 Informative 結案:遵循儲存庫的設定,屬於使用者的工作區信任決定。

信任決定,實際上是什麼

Anthropic 的回覆與 Claude Code 的運作方式一致。當你開啟一個資料夾,系統會問你是否信任它,而你說了是。這個決定涵蓋 CLAUDE.md、hooks、子代理與測試套件。

問題在於你當時決定的是什麼。你信任這個儲存庫的程度,是足以看看它。這正是你複製它的目的。在筆電上,「信任這個資料夾到可以執行它的測試」,等於把你的帳號能觸及的一切都交給它,因為 pytest 就在那裡執行。Straiker 列出了植入程式拿到的東西:你能讀取的每個檔案、記憶體中的憑證、磁碟上的 SSH 金鑰,以及每個你能推送的儲存庫。

沒有任何審查者,無論是人還是模型,會讀完每個受評估專案的每個測試檔。這次的審查是被刻意縮短的,但許多誠實的審查同樣很短。你能選擇的,是測試套件在哪裡執行。

在筆電上:儲存庫寫好審查,測試以你的身分執行northstar-metrics(儲存庫)CONTRIBUTING.md「執行 first-pass 代理」.claude/agents/first-pass.mdmodel: haikuscope: src/ docs/tests/test_compat.py在審查範圍之外沒有越獄,沒有 CVE:只用有文件的功能Claude Code1. Fable 委派審查2. Haiku 讀 src/ 與 docs/3. 兩項埋好的無害發現4. 審查標記為完成5. $ pytest===== 11 passed =====你的筆電,以你的身分6. 下載酬載並執行7. Sliver 連線開啟植入程式能觸及你能讀的每個檔案~/.ssh/id_ed25519~/.aws .env 權杖推送到每個儲存庫
Straiker 的鏈條在筆電上的樣子。儲存庫把審查引導給一個從不看測試的較便宜模型,pytest 以開發者的身分執行酬載,植入程式繼承了開發者能觸及的一切。使用者唯一看到的,是最後一行。

在 Bromure 機器裡執行同一個儲存庫

在 Bromure Agentic Coding 中,Claude Code 在你 Mac 上的 Linux 虛擬機器裡執行。每台機器都有自己的磁碟、自己的家目錄與自己的憑證,所有流量都經由 Mac 上的代理伺服器離開。工作區信任決定仍然由你來做。改變的是,受信任的儲存庫能觸及什麼。

在 Bromure 機器裡重演 Straiker 的鏈條。第一到第五步照舊發生:代理讀了貢獻指南,子代理審查了錯誤的資料夾,pytest 執行。從第六步起,儲存庫面對的是 Mac。

下載得經過 Mac

酬載得先抓下來。測試解碼出一個 URL,向一台專案從未需要過的主機要一個檔案。在 Bromure 機器裡,對外只有一條路:虛擬網路交換器把每個連往 80 與 443 埠的連線轉進 Mac 上的代理伺服器,其餘所有連線,不論協定,都拿去比對機器的規則。

這些規則位於機器的 Guardrails 面板中的 Outbound connections:依主機、IP 範圍、協定與埠設定的允許與拒絕項目,再加上一個 Unmatched traffic 設定,值為 Allow 或 Deny。一台用來評估陌生人程式碼的機器,只需要一份短清單:模型供應商、套件登錄庫、GitHub。

allow web api.anthropic.com
allow web pypi.org
allow web files.pythonhosted.org
allow web github.com
default deny

在這些規則下,test_compat.py 裡的下載根本連不上,而作者為了掩蓋失敗加上的 try/except: pass,現在掩蓋的正是這一次。pytest 仍然印出 11 passed。差別在 Mac 上。機器的 Security Timeline 中有一列紅色的 Firewall 紀錄,帶著拒絕判定、測試試圖連線的主機與埠,時間戳記就在代理執行 pytest 後幾秒。一個指標用戶端的測試套件沒有任何理由去連一台你從沒聽過的主機,而現在你知道它試過了。

植入程式之後的流量也受到同樣的對待。不論 Sliver 是透過 HTTPS、自有埠上的雙向 TLS、WireGuard 還是 DNS 回連,連線都得經過交換器離開,而交換器拿同一張表比對每一種協定。

植入程式找到的是替身

假設機器允許未符合規則的流量,酬載送到了,Sliver 也開啟了連線。攻擊者現在在虛擬機器裡有一個 shell,能觸及的範圍和 Straiker 為筆電列出的相同。在 Bromure 機器上,這個範圍涵蓋的東西少得多。

  • 檔案。 虛擬機器裡只有它自己的家目錄,以及你選擇與這台機器共享的專案資料夾。你 Mac 的家目錄、其他專案、瀏覽器設定檔與鑰匙圈都不在裡面。
  • 憑證。 Bromure 把真正的 API 金鑰、OAuth 權杖、git 權杖、雲端機密與資料庫密碼加密存放在 Mac 上。虛擬機器握有的是格式正確的替身:ANTHROPIC_API_KEY=sk-ant-api03-brm-…、一個 GitHub 會拒絕的 ghp_ 權杖、一把什麼都簽不了的 AWS 秘密金鑰。代理伺服器在請求離開虛擬機器之後才換上真值,而且只針對該憑證所屬的主機。
  • SSH 金鑰。 ls ~/.ssh/id_* 什麼也找不到。虛擬機器的 SSH_AUTH_SOCK 是一座通往 Mac 上 ssh-agent 的橋。虛擬機器可以要求簽章,但讀不到金鑰。為一把金鑰開啟 Ask before use,每次簽章請求都會在 Mac 上跳出對話框。

一個把能蒐集的東西都送回家的植入程式,會觸發第二條絆線。替身權杖只有一個合法目的地,也就是它被簽發給的那台主機。當代理伺服器看到 sk-ant-api03-brm-… 或那個 ghp_ 權杖被送往其他地方,它會以 HTTP 451 拒絕請求、暫停虛擬機器,並在 Mac 上跳出標題為 This environment may have been compromised 的警示,列出該權杖、它被簽發給的主機,以及它被送往的主機。流出去的只是替身,所以沒有什麼需要輪替。

推送要先問過

Straiker 清單上的最後一項「具推送權限的儲存庫」,正是把一台被入侵的筆電變成供應鏈事件的那一項。一個握有 shell 與你 git 憑證的攻擊者,可以把一個 commit 推送到你維護的每個專案。

在 Bromure 機器上,推送到 GitHub、GitLab 或 Bitbucket 屬於寫入,而新機器出廠時,每個受保護的服務都啟用了 Prompt before write。代理伺服器扣住這次推送,macOS 顯示 Allow write on "github.com" from workspace "…"?,並附上它在線路上出現的原樣操作 POST /acme/app.git/git-receive-pack。你沒有要求推送,於是按下 Don't allow,植入程式收到一個以 blocked by Bromure Guardrails 結尾的 403。macOS 是依據代理伺服器讀到的內容繪製這個對話框,所以虛擬機器裡的任何東西都改不了它的措辭。

追蹤紀錄揭露沒有發生的審查

塵埃落定後,你會想知道代理做了什麼。在預設的追蹤層級 AI request details 下,代理伺服器保存每一次與模型供應商的往來,加密存放在 Mac 上,Trace Inspector 再把每一筆解析成工具呼叫、shell 指令與檔案讀取。

對這個儲存庫來說,紀錄回答了真正重要的問題:

  • 審查是哪個模型做的? 擷取到的請求內容寫著它的名字:claude-haiku-4-5,就在主工作階段讀取 .claude/agents/first-pass.md 的下一輪。
  • 它讀了什麼? 每一個 Read 呼叫及其路徑。有讀取 src/ 與 docs/ 底下檔案的呼叫,沒有任何一個讀取 tests/test_compat.py。
  • 執行了什麼? 啟動 pytest 的那個 Bash 呼叫,旁邊則是 Security Timeline 中它所引發的那次下載的防火牆紀錄。

儲存庫控制得了代理看到什麼、pytest 印出什麼,卻沒有辦法竄改追蹤紀錄。代理伺服器在 Mac 上、直接根據流量本身寫下這份紀錄。

在 Bromure 裡:測試在虛擬機器中執行,出口在 Mac 上機器的虛擬機器:「eval」~/northstar-metrics$ pytest → test_compat.py植入程式會找到的東西ANTHROPIC_API_KEY=sk-ant-api03-brm-…GH_TOKEN=ghp_… (替身)~/.ssh:沒有私鑰只有這台機器的家目錄沒有 Mac 家目錄、沒有其他專案每條連線都經過交換器交換器+代理伺服器,在 Mac 上Firewall:酬載主機 → denySecurity Timeline 中的紅色列替身越界 → 451,虛擬機器暫停追蹤:模型 claude-haiku-4-5,Read src/… docs/…, Bash pytestmacOS 對話框:Allow write on …?POST /acme/app.git/git-receive-pack真金鑰只為所屬主機換上網際網路api.anthropic.compypi.orggithub.com酬載主機:從未被連上留在 Mac 上、虛擬機器之外API 金鑰與 OAuth 權杖 · git、雲端與資料庫憑證 · SSH 私鑰 · 加密的追蹤紀錄 · Security Timeline
同一個儲存庫,在 Bromure 機器裡。審查仍然交給 Haiku,pytest 仍然執行,但虛擬機器裡只有替身憑證、沒有私鑰,酬載下載停在 Mac 上的出口防火牆,推送要等 macOS 對話框,追蹤紀錄則記下哪個模型審查了什麼。

一台給你沒讀過的程式碼用的機器

Straiker 的文章以三項建議作結:執行儲存庫定義的子代理前須先核准、執行測試檔之前先讀過,以及把 .claude/agents/、hooks 與工作流程定義這類檔案視為不受信任的輸入。三項都很合理,而三項都仰賴有人每一次都記得,而且是在一件看似例行審查的工作上。

在 Bromure 裡,安全的習慣只要點幾下。新機器是已簽署 Ubuntu 映像的寫入時複製(copy-on-write)分身,幾乎不占磁碟空間。由此可得出一套評估外部程式碼的設定:

  1. 建立一台名為 eval 的機器。 只給它一個模型供應商,其他什麼都不給:沒有具推送權限的 git 權杖、沒有雲端金鑰、沒有資料庫。
  2. 把它的對外連線設為預設拒絕。 在 Guardrails › Outbound connections 中允許模型供應商、套件登錄庫與 GitHub,並把 Unmatched traffic 設為 Deny。
  3. 複製到虛擬機器裡,而不是共享資料夾。 在機器家目錄中的一個新資料夾裡開始工作階段。共享資料夾是一扇即時通往你 Mac 的窗,而機器自己的家目錄,才是 Bromure 能倒回或清除的部分。
  4. 或者讓它用完即丟。 bromure-cli vm run --rm 啟動的機器在停止時會連同磁碟一起刪除。
  5. 採用相依套件之前,先讀 Timeline。 測試執行期間出現的一列 Firewall 拒絕紀錄,是一項沒有任何審查者回報的發現,不論 pytest 印出了什麼。

如果還是有東西溜過去,你清理的只是一台機器。Rewind home… 會把家目錄還原到先前某次開機時的檢查點。被入侵偵測器標記的機器會拒絕開機,直到你選擇 Wipe and Launch,它會重建磁碟與家目錄,同時保留你的設定與 Mac 上的真實憑證。你筆電的帳號、其他專案與其他機器,從來都不在裡面。

等這個程式庫證明了它的價值,再把它移到做正事的機器上。那台機器同樣只握有替身金鑰,它的推送同樣會停在 Mac 上的對話框前。

下載得先問過

虛擬機器開啟的每一條連線,都會經過 Mac 上的虛擬交換器與代理伺服器。拒絕未符合規則的流量後,從新主機抓取酬載的測試會失敗,而 Security Timeline 會記下這次嘗試。

shell 找到的是替身

真正的 API 金鑰、git 權杖與雲端機密都加密留在 Mac 上,SSH 私鑰從不進入虛擬機器。替身一旦被送往它所屬主機以外的地方,就會回傳 451 並暫停機器。

推送需要在 Mac 上點一下

在新機器預設的 Prompt before write 下,git push 會等待一個顯示確切操作的 macOS 對話框。植入程式無法回應它。

儲存庫改不了的紀錄

Mac 上的追蹤紀錄顯示是哪個模型審查了程式碼、讀了哪些檔案、接著執行了哪個指令,不論終端機印出了什麼。

少交一點給儲存庫

這個儲存庫沒做什麼奇特的事。它寫了一份貢獻指南、定義了一個輔助代理,並附上一個測試檔。專案會繼續做這三件事,因為它們有用;代理也會繼續遵循它們,因為遵循專案的慣例,正是讓代理把工作做好的原因。

Anthropic 的回覆把決定交給你,而這也正是它該在的地方。在一台為此打造的機器上做這個決定:在那台機器上,信任一個儲存庫到可以執行它的測試,代表的只是交給它一台虛擬機器、幾個替身和一份短短的主機清單。然後,就讓測試套件想印什麼就印什麼吧。