任何人都能發布那個套件
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 權杖抵達的。 這一次,攻擊者連偷都省了。
第一波根本沒有安裝指令稿可以找
拿那前面八個版本去掃危險的安裝 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 存在。
這道閘門走到那一步,沒有用到簽章、沒有用到信譽分數,也沒有用到任何公告。它讀的 是一個發布日期。這在這裡很重要,因為第二波是在第一則公開警告發出後不到一分鐘就 出貨的,而任何要等報告的防禦,都已經是慢了一步才進場。
就算它還是跑了,那趟掃描會找到什麼
假設你為某個套件把閘門關掉了,或者那個版本在有人發現之前就已經放到過了門檻。 酬載還是得做四件事,而在一個 Bromure 設定檔裡,每一件都會撞上一個住在 Mac 上、 客體搆不到的控制。
它得跑起來。 Supply Chain → Strip install scripts 會即時把 preinstall、
install、postinstall 和 prepare 從 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 風格的規則:
一個動作、一個協定(tcp、udp、web 或 any)、一個主機名稱或一段 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 視窗,
而客體的程式碼編輯不了它,因為客體的程式碼搆不到它。
為什麼這件事一再發生
信任發布和來源證明是值得有的東西。它們把一年前最主流的那種攻擊,也就是竊取維護者 的 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,然後別去動那道存在時間閘門。