破綻在於時間戳
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 當作緩解手段;這場行動是在模組被匯入時執行,而非透過生命
週期掛鉤」。
兩個彼此無關的攻擊者,相隔三天,都在生態系旗艦修正上線的同一週落在同一步棋 上。這不是巧合。當一項防禦點名了某個機制時,就會這樣——那機制成了立足點,而 站到別處對攻擊者只需幾行程式。一個問「這個套件有安裝掛鉤嗎?」的掃描器,會從 AsyncAPI 得到誠實的「沒有」,然後放它過關。
唯一沒有移動的
拿掉遞送機制,看看在兩起入侵中維持不變的是什麼。jscrambler 的版本只上線了 幾個小時。AsyncAPI 的穩定版在 08:30 UTC 發布,下游偵測在 08:49 開始——十九 分鐘。兩起事件裡每一個惡意版本都在上線後遠不到兩小時內被撤下。有效負載從安裝 掛鉤搬到了進入點;遞送從生命週期指令碼變成了執行期載入器。唯一沒有移動的 性質,是每一個被下毒的版本都是嶄新的。一個剛發布的版本,是入侵共有的指紋, 因為那正是攻擊的形狀——偷一個權杖、發布、被逮到、被撤下。這個窗口在結構上 就很短。
那正是 Bromure 的時效閘門所檢查的性質,而且它預設開啟。
每一次套件取得——npm、PyPI、Cargo 等等——都會在代理程式的安裝看到任何一個
位元組之前,先通過主機的中間人代理,而代理會拒絕任何比門檻更年輕的版本,
預設是兩天。像 latest 這樣的浮動參照或一個 semver 範圍,會靜默解析到夠老的
最新版本;一個固定指向過新版本的參照,會連同一則解釋原因的 Bromure 錯誤,以
451 回傳。它從不過問有效負載在哪裡。它不在乎程式是在 preinstall 還是在
require() 觸發、掛鉤在不在、--ignore-scripts 有沒有設定。一個十九分鐘前
發布的版本會被拒絕,因為它十九分鐘大。這兩場行動面對兩天的閘門,一到就已
陣亡——不是因為 Bromure 認出了有效負載,而是因為它根本從未看過有效負載。
時效閘門並不是在主張新套件是惡意的——大多數都沒問題,而兩天大的版本仍然很 新。它主張的是風險集中在哪裡。入侵的危險窗口,是在被偷的權杖推上一個版本 之後、維護者察覺並撤下它之前的最初幾個小時。等兩天並不會讓套件變安全,但它 把你的安裝移出這些攻擊所生存的那個確切區間,而且是在完全不檢查你即將執行的 任何一行程式的情況下做到。對於你在發布當天就需要的套件,閘門接受逐套件的 豁免;其餘一切都等它的兩天。
當一個版本反正已經夠老時
時效閘門是過濾器,不是牆。設一個更短的門檻、豁免一個你第一天就需要的套件, 或撤下一個未被偵測到、久到老過兩天的惡意版本,被下毒的程式就會抵達安裝。 Bromure 對那種情況的答案,正是先前那些供應鏈文章所描述的,也正是時效閘門 得以只當一個過濾器、而非整套防禦的原因——執行安裝的代理程式,不在你的 Mac 上。
它在一個用完即棄的 Linux VM 裡,隔著一道 Hypervisor 邊界。所以當 AsyncAPI 的
載入器觸發、它的憑證收集器出動搜尋時——而它搜得很兇,掃過超過一百個環境
變數,
包括 NPM_TOKEN、AWS_ACCESS_KEY 與 ANTHROPIC_API_KEY,再加上磁碟上的
.npmrc、~/.aws/credentials、id_ed25519 與 kubeconfig——它找到的是 VM
的副本,而那些是佔位用的 brm_… 字串。真正的值住在主機上。當代理程式發出
一個需要用到它的正當請求時,主機代理會在線路上、僅為那一跳,把假的換成真正的
值,再換回來;祕密永遠不會落到 VM 的記憶體裡供收集器刮取。有效負載複製的
~/.ssh 金鑰,是為那個設定檔鑄出的用完即棄的一對。而有效負載嘗試的 C2
上傳——這次是送往 85.137.53.71——是經由同一個會記錄每個目的地的主機代理
離開,於是一個你從未啟動的 Node 行程對一個未知 IP 開啟通訊端,就是安全記錄
裡的一行,而不是一次靜默的傳輸。當窗口關閉時,VM 以及有效負載在其中所做的
一切都消失了。
這正是今年稍早騎進 Red Hat 自家 npm 範圍的同一個 Miasma 執行期家族。 收集器已然成熟,並在各場行動間重複使用;不斷改變的,是遞送機制那一側。
這一週特別令人不安的教訓是:一項綁定在某機制上的防禦,其半衰期以天計。生態系 花了一年把安裝指令碼變成該盯防的東西、推出了修正,然後眼看著兩個各自獨立的 攻擊者,在發行說明都還沒冷卻之前就把有效負載挪走。這場競賽不能靠點名下一個 機制來贏,因為程式總還有別的地方可以觸發。要贏,得靠檢查一件攻擊者無法廉價 改變的事——版本有多老——並把溜過去的東西,放在一個它什麼都帶不走的地方執行。 試試看。