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

舊名字照樣能發布

10 月 13 日,Bloom Security 揭露了 SlugFest:npm 的信任發布以「擁有者/名稱」字串比對 GitHub 儲存庫,所以任何人只要搶下一個已改名或已刪除儲存庫的舊名字,就能替不屬於自己的套件發布新版本,完全不必偷任何憑證。Bloom 找到超過 1,500 個曝險套件,總下載量超過 1,000 萬次,並點名 AI 代理就是在無人看管下拉進新版本的安裝者。在 Bromure Agentic Coding 裡,這麼新的版本對代理來說並不存在,安裝在只放了替身的 VM 裡執行,而打開漏洞的那次改名會等你點一下。

npm 的信任發布記住了儲存庫的名字,卻忘了那是哪一個儲存庫。維護者一改名,下一個用這個名字建立儲存庫的人就能發布。

10 月 13 日星期一,Bloom Security 的 Golan Myers 發表了 SlugFest, 這是 npm 信任發布(trusted publishing)的一個漏洞。The Hacker News 當天就做了報導。Myers 用一把鎖來解釋:「這就像一把只核對門牌、不核對屋主是誰的鎖;屋主搬走了,誰搬進來誰就拿到鑰匙。」

Bloom 算出超過 1,500 個曝險套件,總下載量超過 1,000 萬次,每月超過 100 萬次。這個攻擊「不需要內部知識、不需要竊取憑證,也不需要競態條件」。Bloom 沒有公布是哪些套件:「由於問題仍然普遍存在,我們不公開受影響套件的清單。」

Bloom 也點出了現在是誰在安裝這些套件。依它的統計,一個程式代理平均每月安裝 70 個套件,一台開發者機器約 350 個。用 Bloom 的話說,透過被搶回的名字發布的惡意版本「會被拉進來,在沒有人參與的情況下執行」。

信任發布是怎麼核對儲存庫的

npm 在 2025 年 7 月向所有人開放了信任發布。維護者不必再把 npm 權杖存在 CI 裡,而是執行 npm trust github --allow-publish,告訴 npm 哪個 GitHub 儲存庫和哪個 workflow 檔案可以發布這個套件。發布時,GitHub Actions 簽發一個描述這次 workflow 執行的短效 OIDC 權杖,npm 拿它和綁定比對,再交回一個發布憑證。沒有任何長效祕密可偷。

綁定把儲存庫存成一個 slug,owner/repo,再加上 workflow 的檔名。它沒有存 GitHub 終身指派的數字 repository_id 與 repository_owner_id。npm 要等第一次發布成功後才記錄這兩個值。在那之前,任何掛著正確名字、跑著正確 workflow 檔案的儲存庫都對得上。

GitHub 上的名字會空出來。維護者把儲存庫改名、移轉給某個組織、刪除,或是改了自己的帳號名稱。舊的 slug 就此釋出,別人可以註冊。Bloom 的步驟如下:

  1. 在空出來的 slug 建立一個儲存庫。GitHub 給它一個新的數字 ID。
  2. 加入一個檔名和綁定相同、具備 id-token: write 權限的 workflow,並執行它。
  3. GitHub 簽發一個 OIDC 權杖,其中的儲存庫 claim 是舊的 slug。
  4. npm 的交換端點比對 slug 和檔名後,發出發布憑證。數字 ID 就在權杖裡,但 npm 手上沒有東西可以拿來比。
  5. 替一個不屬於你的套件發布新版本,連同它的名字、它的下載量,以及所有依賴它的專案。

Bloom 用自己掌控的 npm scope 和儲存庫把整條鏈從頭到尾跑了一遍,從另一個只有 slug 相符的組織發布成功。GitLab 的信任發布也有同樣的缺口。

這樣產出的版本甚至帶著簽章。從公開儲存庫進行的信任發布會產生來源證明,而這份證明會在維護者設定的 slug 底下,指向攻擊者儲存庫裡的 workflow 執行。8 月時,pull request 上一則兩個字的留言就讓一個專案自己的發布管線推出了十個帶著有效來源證明的惡意版本。SlugFest 連受害者的管線都用不著。

空出的名字、相符的 workflow、一張發布憑證npm trust 綁定repo: acme/sdkworkflow: release.ymlrepo_id: (未儲存)維護者改名acme/sdk → acme/sdk-v2「acme/sdk」空出來,誰都能再註冊攻擊者的新儲存庫 acme/sdkrepository_id: new.github/workflows/release.ymlpermissions: id-token: writeOIDC 權杖:repository = acme/sdknpm 交換:slug ✓ workflow ✓ID:沒有東西可比→ 發出發布憑證npm 登錄庫@acme/[email protected]標記為 latest附有來源證明某個程式代理:npm install @acme/sdk解析 latest,執行它,沒人在看名稱僅為示意。Bloom 找到超過 1,500 個處於這種狀態的套件,尚未公開清單。
SlugFest 的攻擊鏈。綁定只存了名字,改名讓名字空出來,用舊名字建立的新儲存庫通過 npm 的檢查,以原本套件的身分發布。

修補花了四個月

Bloom 在 6 月 6 日透過 GitHub 的 HackerOne 計畫回報了這個漏洞。GitHub 在 6 月 9 日確認。最後一次有實質內容的回覆是 7 月 8 日,說團隊「正在修正這個問題,並評估其風險與嚴重程度」。之後六次追問和一次向 HackerOne 提出的調解請求都石沉大海。

GitHub 10 月 2 日的 changelog 帶來了修補。建立後 48 小時內都沒有發布過的信任發布綁定,現在會失效,不能再授權發布。第一次發布成功後,綁定就會綁上儲存庫的數字身分。要換儲存庫,就得建立新的綁定,重新開始 48 小時的期限。npm 現在也會拒絕由 issue_comment workflow 簽發的權杖,那正是 8 月那次攻擊的觸發點。

changelog 沒有說 10 月 2 日以前建立的綁定怎麼處理。Bloom 給維護者的建議是:對每個套件執行 npm trust list,確認每個 slug 仍指向自己掌控的儲存庫,並在每次改名或移轉之後重建綁定。

如果你是安裝套件而不是發布套件,這個建議對你毫無幫助。從 npm install 看不到一個套件的信任綁定。那 1,500 個名字沒有公開。從被搶回的 slug 發出的惡意版本,看起來和任何其他版本一樣,連來源證明都有。Bloom 針對代理情境的原話是:「它只需要是代理挑中的那個套件。」

同樣的安裝,換到 Bromure 機器上

在 Bromure Agentic Coding 裡,代理跑在你 Mac 上的 Linux VM 中,VM 發出的每個請求,包括 registry.npmjs.org,都會經過主機上的代理伺服器。供應鏈政策在這台代理伺服器裡執行,位於 VM 之外,所以代理或套件在機器裡做什麼都改不了它。讓我們跟著 SlugFest 的版本進入一台 Bromure 機器。

這麼新的版本不會被列出

每台新機器都預設開啟存在時間閘門,最少兩天。當 npm 向登錄庫要 @acme/sdk 時,代理伺服器會把過去兩天內發布的版本全部從清單中移除,並把 latest 移到夠舊的最新版本。

代理執行 npm install @acme/sdk,和它在任何地方會跑的指令一樣,拿到的是維護者最後一個真正的版本。攻擊者的版本不在代理看到的清單裡。如果代理因為某個網頁或 issue 叫它這麼做,而指定了版本號,tarball 請求就會收到 HTTP 451,npm 會在代理的終端機裡印出原因:

Bromure Supply-Chain Security blocked this request: npm package @acme/[email protected]
published 3 hours ago — policy requires 2 days minimum

閘門只看發布日期,所以你不需要 Bloom 的清單,也不需要惡意程式特徵碼。搶回 slug 的攻擊者必須發布新版本,而新版本就是年輕的版本。代理安裝前一個版本的同時,維護者、npm 和掃描器有兩天時間,去注意到一個團隊裡沒人發過的版本。綁定從沒用過的維護者,在它第一次發布時,有充分理由去看一眼。

兩天之後還有更多檢查

在機器的 Supply Chain 面板開啟更多防護層,版本一通過閘門就會遇上它們。選了 socket.dev,每個 tarball 在送出前都會被檢查,被標為惡意程式的套件會收到 451。開啟 OSV 檢查,已有公開安全公告的版本會在你選的嚴重程度上被擋下。開啟 Strip preinstall / install / postinstall / prepare from npm tarballs on the fly,代理伺服器會在 npm 看到 tarball 之前移除其中的安裝 hook,拿掉大多數 npm 惡意程式賴以投遞的管道。

酬載找到的是替身

假設某個版本還是進來並執行了。它是在 VM 裡執行,而那裡的憑證都是替身。GH_TOKEN、~/.git-credentials、Anthropic 金鑰、AWS 金鑰:每一個都是和真品形狀相同的字串,由真實值和每次安裝獨有的 salt 推導而來,除了經由你 Mac 上的代理伺服器、送往它所屬的主機之外,在哪裡都沒用。酬載把它們送到自己的伺服器,送出去的只是到處都無法通過驗證的字串。

那個請求同樣會經過代理伺服器,它會檢查每個對外請求,看有沒有替身被送往錯誤的主機。一個正要送往收集伺服器的 ghp_ 替身會收到 451,VM 隨即暫停,Mac 上會開啟 This environment may have been compromised,列出該憑證的名稱、它所屬的主機,以及它被送往的主機。

你的改名要等你點一下

SlugFest 始於一次改名、一次移轉或一個被刪除的儲存庫,而代理也會做這類整理工作。在 Bromure 機器裡,GitHub 預設處於 Prompt before write 之下。改名儲存庫是對 GitHub API 的 PATCH,刪除則是 DELETE。兩者都會停在一個 macOS 對話框 Allow write on "github.com" from workspace "…"?,顯示線路上實際出現的方法和路徑。當代理整理專案、把 acme/sdk 改名為 acme/sdk-v2 時,你會在舊名字空出來之前看到它,也知道該重建哪一個 npm 綁定。

清單公布的時候

Bloom 遲早會公布受影響的套件。到時就會有人問:你的哪台機器裝過其中一個,什麼時候裝的。Security Timeline 會記錄每台機器的每一次套件下載,不論政策為何:npm · @acme/[email protected]、機器、時間,以及它是被下載、被存在時間閘門擋住,還是被封鎖。用套件名稱篩選,把結果匯出成 CSV。紀錄存在 Mac 上,代理改不了。

在 Bromure 裡:對代理來說,新版本還不存在機器 VM(Ubuntu)$ npm install @acme/sdk+ @acme/[email protected]$ npm i @acme/[email protected]451 published 3 hours agoGH_TOKEN=ghp_… (替身)ANTHROPIC_API_KEY=sk-ant-api03-brm-…VM 裡沒有真正的祕密可拿代理伺服器,在 Mac 上存在時間閘門(預設 2 天):未滿 2 天的移除,latest → 4.1.3選用:socket.dev、OSV、移除安裝腳本替身送往別的主機 → 451,VM 暫停PATCH /repos/acme/sdk(改名)→ Prompt before write 對話框政策在主機上,代理碰不到npm 登錄庫4.2.0 3 小時前來自被搶回的 slug4.1.3 5 個月前維護者的版本兩天時間讓維護者、npm 和掃描器注意到它Security Timeline,在 Mac 上npm · @acme/[email protected] · 已下載 · 機器、時間 · 依套件篩選,清單公布時匯出 CSV
同樣的安裝,換到 Bromure 機器上。剛發布的版本不在代理看到的清單裡,指定版本的請求收到 451,即使酬載真的執行了也只找到替身,而每一次下載都有紀錄。

新版本不會被列出

存在時間閘門預設為兩天,會把新鮮的版本從登錄庫清單中移除,並把 latest 往回移。代理安裝的是維護者最後一個真正的版本,完全不知道發生過什麼事。

指定版本的請求收到 451

如果代理用版本號要求新版本,下載會被拒絕,npm 會在代理的終端機裡印出套件的存在時間和政策。

酬載找到的是替身

在 VM 裡執行的任何東西,找到的都是替身權杖。被送往錯誤的主機時,它會收到 451,VM 暫停,Mac 跳出警示。

改名和安裝都有紀錄

Prompt before write 會攔住讓 slug 空出來的儲存庫改名。Security Timeline 依機器和版本記錄每一次套件下載。

下一個名字

GitHub 的修補會在第一次發布後把綁定綁上儲存庫的數字身分,並讓從未發布的綁定失效,這就補上了這個缺口。信任發布保護的是發布憑證,並告訴你是哪條管線建置了這個版本。8 月那次,管線是受害者自己的,被陌生人的一則留言觸發。這一次,是陌生人的儲存庫掛上了正確的名字。兩次的版本都帶著來源證明。

你的代理會安裝解析器回傳的任何東西,依 Bloom 的統計每月 70 個套件。讓它們在 Bromure 機器裡執行:在那裡,兩天內發布的版本還不存在,即使酬載真的執行了,也只找得到替身。想多了解存在時間閘門,請讀任何人都能發布那個套件與撤銷權杖,就是引爆點。