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

任何人都能發布那個套件

8 月 28 日,有人對一個熱門的 OpenAPI 程式碼產生器開了一個 pull request,在上面留了兩個字的留言,然後這個專案自己的發布工作流程就把十個被下毒的版本推上了 npm,還帶著有效的來源證明。攻擊者從頭到尾沒有握有任何憑證。第一波完全沒有安裝指令稿,而是把酬載藏在 node-gyp 會拿 Python 去求值的地方。整個窗口總共三小時十一分鐘,而 Bromure Agentic Coding 的存在時間閘門預設就開著、設在兩天,在對這場攻擊一無所知的情況下就贏過了這段時間。

有人從一個 fork 開了 pull request,在上面留了兩個字的留言。專案的發布管線讀了 那則留言,把他們的程式碼取出來,執行,然後為跑出來的成品簽了名。走到這一步, 他們什麼都沒偷。

@7nohe/openapi-react-query-codegen 會把 OpenAPI 綱要變成 TanStack Query 的 hooks。你把它指向一份規格,它就替你寫出用戶端程式碼。Aikido 統計到每週超過 150,000 次下載。你會加進這種套件,是因為它替你省下一個下午,而你的編碼代理連問 都不會多問一次就裝了。

8 月 28 日,它開始出貨一支憑證竊取程式。

Aikido Security 當天就發表了分析, SafeDep 也發表了自己的一份, 裡面有工作流程的機制,還有一份逐分鐘的時間軸。這支惡意程式在自己的字串裡替自己 命了名:Trinitite。兩個團隊都把它的手法歸進 Mini Shai-Hulud 家族,也就是 今年一整年都在一個個維護者帳號之間穿行的那隻會自我複製的 npm 蠕蟲。

真正值得一讀的,是它走上 npm 的那條路。

發布管線接受了一個陌生人的指令

專案的 release.yml 有一個 issue_comment 觸發器。那是 GitHub Actions 的一個 事件,只要有人在 issue 或 pull request 上留言就會觸發,而且是個常見的便利做法: 維護者把它接起來,好讓自己打一個字就能發版,而不必在介面上一路點下去。

這一個沒有作者身分的檢查。GitHub 會告訴工作流程,寫下這則留言的人是不是儲存庫的 擁有者、是不是成員、是不是過去的貢獻者,或者根本就是個陌生人,而工作流程得自己 去看。這一個沒有去看。

SafeDep 把整個順序追了出來。從 fork 開一個 pull request。在上面留言 npm publish。管線醒過來,取出攻擊者 fork 裡的那份程式碼,然後跑 pnpm install,這一跑就在專案自己的 runner 上執行了攻擊者的 preinstall hook。 那個 job 帶著 id-token: write,足夠鑄出一枚 npm 的 OIDC 信任發布權杖。

十個惡意版本就這樣出去了,每一個都帶著有效的來源證明,因為每一個確實都是 專案自己的管線建出來、發出去的。Aikido 那句話最值得留著:「當工作流程本身被 攻陷時,那張憑證就成了一個不可靠的信任訊號。」

去查一個套件的來源證明,你只會知道一件事:它是不是從那個儲存庫的管線裡出來的。 在這裡,它就是。沒有人偽造任何東西,而你可以握著一個正確的簽章,底下蓋的卻是 惡意程式,兩件事看起來都不奇怪。五月我們 看過同樣的結局,是透過一枚被偷走的 OIDC 權杖抵達的。 這一次,攻擊者連偷都省了。

一個陌生人從 fork 開的 PR,然後留言npm publishrelease.ymlon: issue_comment沒有作者檢查取出那個 forkpnpm install跑攻擊者的程式碼id-token: writenpm OIDC信任發布鑄出權杖照要求,如設計所定10 個版本發布到 npm有效的來源證明而且它不是謊話這條鏈上沒有任何一步需要偷來的機密。最弱的一環,在所有簽章的上游。
一個手上沒有任何憑證的人,是怎麼讓十個簽了名的版本被發布出去的。發布工作流程在一則留言上觸發,卻沒有檢查是誰寫的,取出了攻擊者 fork 裡的程式碼,在安裝相依套件時執行了它,再用自己的 id-token 權限鑄出一枚合法的 npm 發布權杖。成品上的來源證明是準確的:專案自己的管線確實建了它、也發了它。

第一波根本沒有安裝指令稿可以找

拿那前面八個版本去掃危險的安裝 hook,你會空手而回。它們的 package.json 裡沒有 宣告任何會執行的東西。

酬載在 binding.gyp 裡,也就是 node-gyp 用來編譯原生模組的那份建置描述檔。 Aikido 解釋了這個花招:「當 npm install 處理一個含有 binding.gyp 的套件時, 它會叫起 node-gyp 去編譯原生模組。node-gyp 會用 Python 對檔案裡的 conditions 欄位求值,也就是說任意的 Python 運算式都可以放在那裡,而且會在安裝 期間被執行,就算 package.json 裡完全沒有宣告 preinstall 指令稿也一樣。」

於是攻擊者把一段 Python 運算式寫進了建置檔。它沿著 Python 內部的子類別樹一路走 到 catch_warnings,用那個類別摸到 __builtins__,從那裡匯入 os,再對隨 tarball 一起出貨的一支 JavaScript 檔呼叫 os.system()。SafeDep 指出,這段走訪 是用 Unicode 跳脫序列寫的,所以拿 os.system 去 grep 一樣是空的。

底下那支 JavaScript 會拆掉三層 —— XOR、接著 AES-128-GCM、再接著一套商用混淆器 —— 然後才從 GitHub 把 Bun 1.4.0 抓下來,放進一個以 trinnyyyy- 為前綴的暫存 目錄,去跑真正的酬載。

十九分鐘之後,攻擊者出了第二波,把 preinstall 又加回 package.json 當備案。 SafeDep 把那一波的時間標在第一份公開安全報告發出後不到一分鐘。操盤的人一邊 讀著揭露內容,一邊針對它出貨。

三小時十一分鐘

SafeDep 的時間軸用的是 UTC。最後一個乾淨的版本 3.0.2 在 8 月 11 日出去。 第一波落在 8 月 28 日 20:00,20:02 結束。第二波跑的是 20:19 到 20:21。 npm 大約在 23:11 把十個版本全數下架。

這留下一個三小時十一分鐘的曝險窗口。一個用 ^3.0.0 的專案,也就是 npm 替你寫的 那個再普通不過的插入號範圍,會直接解析進去。如果你在那個窗口裡跑了一次乾淨安裝, 或者你的 CI 跑了,或者你的編碼代理跑了,你就拿到了那隻蠕蟲。

一旦跑起來,它就用超過 150 個 glob 樣式把家目錄掃了一遍:SSH 私鑰、.env 檔、 ~/.docker/config.json、AWS、Azure 與 GCP 的憑證,npm、PyPI 與 RubyGems 的權杖, Kubernetes 的服務帳號權杖,加密貨幣錢包。同一份清單上還坐著 ~/.claude.json~/.claude/~/.claude/mcp.json,也就是編碼代理自己的 設定,以及它會去講話的每一台 MCP 伺服器的位址。

在搆得到的儲存庫裡,它會寫下一份 .claude/settings.json,帶著一個 SessionStart hook,每當開發者用 Claude Code 打開這個專案時就跑一次 setup.mjs。用剛剛收集到的登錄檔權杖,它把自己重新發布到受害者在 npm、PyPI 和 RubyGems 上維護的每一個套件裡。這就是蠕蟲的那一部分:每一個被它落腳的開發者, 都成了下一次發版的候選人。

兩天贏過三小時

現在把一個 Bromure Agentic Coding 的設定檔放進這條路徑。

Bromure 把每個設定檔都跑成 Apple Silicon 上自己的一台虛擬機,而代理抓的每一個 套件(npm、PyPI、Cargo、RubyGems、Maven、NuGet、Go 模組、Packagist)在任何一個 位元組抵達客體之前,都要先經過 hypervisor 上 Mac 那一側的代理伺服器。

那個代理伺服器第一件檢查的事,就是這個版本有多新。**Supply Chain → 存在時間閘門 預設就開著,最少兩天。**浮動的參照(latest、插入號範圍、波浪號範圍)會安安靜靜 地解析到早於門檻的最新版本。你要是釘住某個太新的版本,就會拿到一個 451,附上 清楚的錯誤訊息。

把這個拿去對上三小時十一分鐘的敵對窗口。8 月 28 日 20:30 UTC,攻擊正在進行時, 你的清單裡寫著 ^3.0.0,你跑 npm install,你的代理解析到的是 8 月 11 日的 3.0.2。它壓根不會知道 3.0.3 存在。

這道閘門走到那一步,沒有用到簽章、沒有用到信譽分數,也沒有用到任何公告。它讀的 是一個發布日期。這在這裡很重要,因為第二波是在第一則公開警告發出後不到一分鐘就 出貨的,而任何要等報告的防禦,都已經是慢了一步才進場。

8 月 11 日3.0.2 — 最後一個乾淨版8 月 28 日 20:00 / 20:19 UTC第一波 (binding.gyp)、第二波 (preinstall)23:11 UTCnpm 下架全部十個曝險:3 小時 11 分存在時間閘門,預設兩天:插入號範圍會取早於門檻的最新版本 —— 3.0.2。
攻擊窗口對上預設門檻。十個被下毒的版本在 npm 上存在了三小時十一分鐘。Bromure 的存在時間閘門出廠就開著、最少兩天,所以插入號範圍會解析回 8 月 11 日的 3.0.2,那些被下毒的版本從頭到尾都不是候選。這道閘門不對套件下任何判斷;它只是拒絕當第一個。

就算它還是跑了,那趟掃描會找到什麼

假設你為某個套件把閘門關掉了,或者那個版本在有人發現之前就已經放到過了門檻。 酬載還是得做四件事,而在一個 Bromure 設定檔裡,每一件都會撞上一個住在 Mac 上、 客體搆不到的控制。

它得跑起來。 Supply Chain → Strip install scripts 會即時把 preinstallinstallpostinstallprepare 從 npm 的 tarball 裡拿掉,重寫 tarball,並 更新登錄檔的中繼資料雜湊,好讓 npm 自己的驗證照樣過關。第二波就這樣沒了。你還可 以往上疊更多檢查:OSV 漏洞查詢,以及透過 socket.dev 的套件過濾,它那個 「封鎖被攻陷套件」的檢查正好涵蓋這一類東西(流氓安裝指令稿、惡意程式),或者用 Delpi 當替代登錄檔。這些沒有一個會去問「有沒有證明」,而當證明是真的時候, 這一點就很要緊。

它得找到東西。 那趟 150 個 glob 的掃描,跑的是虛擬機裡的 /home/ubuntu, 不是你 Mac 上的家目錄。資料夾共享是明確指定的,上限是八個。它真的找到的憑證都是 誘餌:客體裡的權杖是 brm_… 這樣的佔位字串,由主機端的代理伺服器在線路上換成 真正的值,所以真正的材料從來不會進到 VM 的位址空間。SSH 私鑰 根本不在客體裡:主機上每個設定檔各 有一支 ssh-agent 負責簽章,而 ~/.docker/config.json 裡放的是假的 base64。 kubeconfig 是合成的,配上用完即丟的用戶端憑證。AWS 的請求會在主機端重新簽章, 所以任何繞過代理伺服器的東西拿回來的是 InvalidSignatureException,而不是通過 驗證。替某個憑證打開 Require approval to use,每一次替換都會在你的 Mac 上 跳出一個對話框,給出有時限的授權。

它得往外打電話。 Guardrails → Outbound connections 是一套 pf 風格的規則: 一個動作、一個協定(tcpudpwebany)、一個主機名稱或一段 IPv4 CIDR、連接埠,而如果是 web,還有你允許的 HTTP 方法。規則由上往下比對,先中的 先贏,而把 Unmatched traffic 設成 Deny,就把這份清單變成了允許清單。同一份 政策會被兩個各自獨立的層評估兩次:虛擬交換器用目的 IP 和從 DNS 偷聽到的主機名稱, 跨所有協定;主機端的代理伺服器則用 TLS SNI 和方法。去抓一個 Bun 的二進位檔,和 把偷到的機密推進一個死信箱儲存庫,兩件事都需要一個目的地,而目的地必須在清單上。

它得發布出去。 Guardrails → GitHub 設成 Read-only 時,git-receive-pack 被當成寫入,REST 的寫入也會被擋,回傳一個硬邦邦的 403,代理讀起來就像一次普通的 API 失敗。沒有被下毒的提交,沒有死信箱,也沒有接下去的發版。同樣這幾種模式也涵蓋 GitLab、Bitbucket、Kubernetes、AWS、DigitalOcean 和容器登錄檔。

它本來想留給下一次的那個 SessionStart hook,坐在一個由 Resources → Storage 底 下的 Erase home… 抹掉的家目錄裡。寫在家目錄之外的東西,則由 Reset to base… 做同樣的事。這一路上的每一個決定、每一個被放行或被拒絕的目的 地、每一次套件的裁決、每一次憑證的替換,都會落進主機上的 Security Log 視窗, 而客體的程式碼編輯不了它,因為客體的程式碼搆不到它。

蠕蟲還需要什麼1. 在安裝時跑起來binding.gyp,或一個 preinstall2. 找到憑證家目錄上 150+ 個 glob3. 對外連線抓 Bun,把資料放進死信箱4. 繼續往外發布推提交,重新發布套件它在 Mac 上撞到哪裡主機端的套件代理伺服器存在時間、指令稿剝除、OSV、socket.dev 或 Delpi 過濾一個 VM 的家目錄brm_… 佔位字串、假的 base64、合成的 kubeconfig、沒有 SSH 私鑰Outbound connections主機端規則,先中的先贏,在交換器和代理伺服器各查一次Guardrails, Read-onlygit-receive-pack 與 REST 寫入由主機端代理伺服器回 403
Trinitite 跑起來之後還需要的四件事,以及在一個 Bromure Agentic Coding 設定檔裡,每一件各自落在哪裡。執行撞上主機代理伺服器的指令稿剝除與套件過濾。憑證掃描在一個 VM 的家目錄裡找到的是佔位字串。對外連線與發布撞上主機端的規則集和 Guardrails。這些控制沒有一個住在客體裡,所以在客體裡跑的程式碼改不動它們。

為什麼這件事一再發生

信任發布和來源證明是值得有的東西。它們把一年前最主流的那種攻擊,也就是竊取維護者 的 npm 權杖,給堵掉了。這週的這次攻陷從旁邊繞了過去,連一枚權杖都沒碰到:一個 陌生人搆得到發布流程,於是管線就在敵意的程式碼上簽了一份千真萬確的證明。

你沒辦法靠稽核走出這件事,因為下一個出包的工作流程會是別人的。你能做的,是別再 當第一台去跑任何東西新版本的機器。

打開一個設定檔,看看 Supply Chain,確認存在時間閘門是開著的。它本來就該是, 設在兩天,而且不需要你動過手。然後再花兩分鐘:打開 OSV vulnerability check, 有金鑰的話選 socket.dev 或 Delpi,打開 Strip install scripts。在 Guardrails 裡,把 Unmatched traffic 設成 Deny,配一份短短的允許清單,並把 任何不負責出貨的設定檔的 GitHub 設成 Read-only。在 Credentials 裡,對任何能 花錢的東西打開 Require approval to use

然後就讓代理去裝它需要的東西吧。它會拿到 3.0.2。 安裝 Bromure Agentic Coding,然後別去動那道存在時間閘門。