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

破綻在於時間戳

npm 在 2026 年 7 月 8 日推出了它十六年來最大的安全變更——安裝指令碼預設停用。不到一週,兩起各自獨立的 npm 遭入侵事件把有效負載改為在套件被匯入時觸發,而非在安裝時,直接繞過新的預設值,也繞過 --ignore-scripts。整個生態系剛花了一年學會盯防的機制,早已移動了位置。唯一沒有移動的,是惡意版本的新舊——而那正是 Bromure 的時效閘門所檢查的訊號。

整個生態系花了一年學到,npm 的安裝指令碼才是危險。7 月 8 日,npm 把它們 預設關掉。三天後,一起入侵把有效負載移出了安裝指令碼;再過三天,第二起則 完全沒有安裝指令碼。兩者都在你匯入套件時觸發。修正與繞道在同一週交錯而過。

某位開發者在 7 月 14 日把 @asyncapi/generator 加進專案,並執行 npm install。那個套件沒有 preinstall、沒有 install、沒有 postinstall 掛鉤——沒有任何東西可供指令碼掃描器標記,也沒有任何東西需要 npm 全新的預設值 去略過。安裝乾淨地完成。當程式第一次執行 require('@asyncapi/generator') 時,一個接進套件自身進入點的載入器啟動了一個分離的 Node 行程,經 IPFS 拉下 一個 8.2 MB 的第二階段,將它解密,並開始搜捕憑證。安裝過程看起來沒有任何 不對,因為不對的那部分並不發生在安裝時。

為去年的攻擊而發布的修正

將近一年來,npm 供應鏈攻擊的故事都是同一個:一個遭入侵的套件帶著一個 postinstall 掛鉤,而 npm install 會在你的機器上自動執行它。於是 7 月 8 日,npm 推出了版本 12——這個工具十六年來 最大的安全重新設計。來自相依套件的生命週期指令碼現在預設停用:「相依套件的 preinstall、install 與 postinstall 指令碼,除非你允許,否則不會執行」。Git 相依與遠端 URL 來源也變成需要選擇加入。這是實實在在的改善,關上了一年來蠕蟲 走過的一道門。

它關上了一道門。攻擊者早已找到了另一道。

有效負載搬到了匯入時

7 月 11 日,npm 套件 jscrambler 遭到入侵 ——在約三小時內發布了五個惡意版本。前三個,8.14.0 到 8.17.0,「從一個 preinstall 掛鉤執行投放程式」,正是經典模式。接著在行動進行到一半時,攻擊者 換了門。「從 8.18.0 起,安裝掛鉤完全消失——相同的投放程式改為以自我執行函式 注入到 dist/index.js 的頂端」,於是它「在套件被匯入或其 CLI 被執行時觸發, 而非在安裝時」。Socket 直白地點出原因:「這是蓄意的規避:它擊敗只檢查 preinstall/postinstall 指令碼的掃描器,並在 npm install --ignore-scripts 下存活」。

三天後,npm 的 @asyncapi 組織 以同樣的方式遭到入侵,但從一開始就是為此打造的。橫跨四個套件的五個版本—— @asyncapi/specs 6.11.2-alpha.1 與 6.11.2、@asyncapi/generator 3.3.1、 @asyncapi/generator-components 0.7.1、@asyncapi/generator-helpers 1.1.1——在攻擊者利用一個設定錯誤的 GitHub Actions 工作流程外洩了專案的機器人 權杖之後被重新發布。Microsoft 的發現才是這裡的關鍵:「所有受影響的套件在 package.json 中都沒有宣告任何 preinstall、install 或 postinstall 掛鉤」。 載入器改為坐在每個套件正常的進入點裡——specs 是 index.js,產生器則是深藏在 範本裡的一個 validator.js。而緩解建議寫得很明白:「不要把 npm install --ignore-scripts 當作緩解手段;這場行動是在模組被匯入時執行,而非透過生命 週期掛鉤」。

jscrambler · 7/118.18.0: 移除掛鉤AsyncAPI · 7/14沒有掛鉤安裝指令碼之門preinstall / postinstallnpm v12 預設關閉--ignore-scripts 關閉已關閉匯入時之門require() / import載入器在進入點npm v12 不觸及開啟同一有效負載分離的 node 行程8.2 MB 經 IPFS憑證收集器C2 · 85.137.53.71
同一有效負載,不同的門。一年來,有效負載走的是安裝指令碼那道門:npm 會自動執行 preinstall/postinstall。npm v12(7 月 8 日)預設關上了那道門,而 --ignore-scripts 則手動關上它。但第二道門通往同一個房間——那是你匯入套件時執行的程式。jscrambler(7 月 11 日)把投放程式從 preinstall 掛鉤搬進 dist/index.js;AsyncAPI(7 月 14 日)則完全沒有安裝掛鉤,載入器接進套件自身的進入點。關上安裝之門,並不會碰到匯入之門。

兩個彼此無關的攻擊者,相隔三天,都在生態系旗艦修正上線的同一週落在同一步棋 上。這不是巧合。當一項防禦點名了某個機制時,就會這樣——那機制成了立足點,而 站到別處對攻擊者只需幾行程式。一個問「這個套件有安裝掛鉤嗎?」的掃描器,會從 AsyncAPI 得到誠實的「沒有」,然後放它過關。

唯一沒有移動的

拿掉遞送機制,看看在兩起入侵中維持不變的是什麼。jscrambler 的版本只上線了 幾個小時。AsyncAPI 的穩定版在 08:30 UTC 發布,下游偵測在 08:49 開始——十九 分鐘。兩起事件裡每一個惡意版本都在上線後遠不到兩小時內被撤下。有效負載從安裝 掛鉤搬到了進入點;遞送從生命週期指令碼變成了執行期載入器。唯一沒有移動的 性質,是每一個被下毒的版本都是嶄新的。一個剛發布的版本,是入侵共有的指紋, 因為那正是攻擊的形狀——偷一個權杖、發布、被逮到、被撤下。這個窗口在結構上 就很短。

那正是 Bromure 的時效閘門所檢查的性質,而且它預設開啟。 每一次套件取得——npm、PyPI、Cargo 等等——都會在代理程式的安裝看到任何一個 位元組之前,先通過主機的中間人代理,而代理會拒絕任何比門檻更年輕的版本, 預設是兩天。像 latest 這樣的浮動參照或一個 semver 範圍,會靜默解析到夠老的 最新版本;一個固定指向過新版本的參照,會連同一則解釋原因的 Bromure 錯誤,以 451 回傳。它從不過問有效負載在哪裡。它不在乎程式是在 preinstall 還是在 require() 觸發、掛鉤在不在、--ignore-scripts 有沒有設定。一個十九分鐘前 發布的版本會被拒絕,因為它十九分鐘大。這兩場行動面對兩天的閘門,一到就已 陣亡——不是因為 Bromure 認出了有效負載,而是因為它根本從未看過有效負載。

機制檢查 · 檢視有效負載有安裝掛鉤嗎?preinstall / install / postinstallAsyncAPI 誠實回答:沒有通過檢查require() 時觸發Bromure 時效閘門 · 檢視版本這個版本多久前發布?門檻: 2 天, 預設開啟登錄顯示發布於:19 分鐘前已拒絕 · 451有效負載永不解析
決定是否放行一個套件的兩種方式。機制檢查問的是有效負載的問題——有安裝掛鉤嗎?——而 AsyncAPI 誠實地回答「沒有」,所以檢查放它過關。Bromure 的時效閘門問的是版本的問題——它多老了?——而一個十九分鐘前發布的版本,無論其程式在哪裡觸發,都會被以 451 拒絕。機制在一週內、於兩起攻擊之間,從安裝時搬到了匯入時;時效沒有移動,因為嶄新的發布正是入侵的形狀。

時效閘門並不是在主張新套件是惡意的——大多數都沒問題,而兩天大的版本仍然很 新。它主張的是風險集中在哪裡。入侵的危險窗口,是在被偷的權杖推上一個版本 之後、維護者察覺並撤下它之前的最初幾個小時。等兩天並不會讓套件變安全,但它 把你的安裝移出這些攻擊所生存的那個確切區間,而且是在完全不檢查你即將執行的 任何一行程式的情況下做到。對於你在發布當天就需要的套件,閘門接受逐套件的 豁免;其餘一切都等它的兩天。

當一個版本反正已經夠老時

時效閘門是過濾器,不是牆。設一個更短的門檻、豁免一個你第一天就需要的套件, 或撤下一個未被偵測到、久到老過兩天的惡意版本,被下毒的程式就會抵達安裝。 Bromure 對那種情況的答案,正是先前那些供應鏈文章所描述的,也正是時效閘門 得以只當一個過濾器、而非整套防禦的原因——執行安裝的代理程式,不在你的 Mac 上。

它在一個用完即棄的 Linux VM 裡,隔著一道 Hypervisor 邊界。所以當 AsyncAPI 的 載入器觸發、它的憑證收集器出動搜尋時——而它搜得很兇,掃過超過一百個環境 變數, 包括 NPM_TOKENAWS_ACCESS_KEYANTHROPIC_API_KEY,再加上磁碟上的 .npmrc~/.aws/credentialsid_ed25519 與 kubeconfig——它找到的是 VM 的副本,而那些是佔位用的 brm_… 字串。真正的值住在主機上。當代理程式發出 一個需要用到它的正當請求時,主機代理會在線路上、僅為那一跳,把假的換成真正的 值,再換回來;祕密永遠不會落到 VM 的記憶體裡供收集器刮取。有效負載複製的 ~/.ssh 金鑰,是為那個設定檔鑄出的用完即棄的一對。而有效負載嘗試的 C2 上傳——這次是送往 85.137.53.71——是經由同一個會記錄每個目的地的主機代理 離開,於是一個你從未啟動的 Node 行程對一個未知 IP 開啟通訊端,就是安全記錄 裡的一行,而不是一次靜默的傳輸。當窗口關閉時,VM 以及有效負載在其中所做的 一切都消失了。

這正是今年稍早騎進 Red Hat 自家 npm 範圍的同一個 Miasma 執行期家族。 收集器已然成熟,並在各場行動間重複使用;不斷改變的,是遞送機制那一側。

這一週特別令人不安的教訓是:一項綁定在某機制上的防禦,其半衰期以天計。生態系 花了一年把安裝指令碼變成該盯防的東西、推出了修正,然後眼看著兩個各自獨立的 攻擊者,在發行說明都還沒冷卻之前就把有效負載挪走。這場競賽不能靠點名下一個 機制來贏,因為程式總還有別的地方可以觸發。要贏,得靠檢查一件攻擊者無法廉價 改變的事——版本有多老——並把溜過去的東西,放在一個它什麼都帶不走的地方執行。 試試看