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

安裝過程很乾淨

npm 在七月開始預設不再執行相依套件的安裝腳本。9 月 17 日,Checkmarx 公開了一場完全不跟這個決定爭辯的行動:載入器就坐在 `BTree.prototype.set` 裡,也就是這個函式庫存在的理由本身,並在第一次有東西存入值為 100 的鍵時觸發。它在每週兩百萬次下載之上跑了十一週,指揮通道則是 Ethereum Sepolia 上的一份智慧合約。編碼代理程式是最理想的受害者,因為執行程式碼正是它的工作。Bromure Agentic Coding 在那段程式碼跑起來之前,就決定它能碰到什麼。

封鎖安裝腳本是對的決定,npm 生態系在七月做出了它。這場行動把執行的時刻挪到你第一次 使用該套件的時候:安裝依然乾淨,而扣下扳機的是你的測試套件。

你的代理程式需要一個有序映射。它從 npm 挑了一個 b-tree 套件,加進 package.json, 然後安裝。什麼都沒跑。npm 12 不再執行相依套件的安裝腳本,所以沒有 postinstall、 沒有 preinstall、沒有建置掛鉤,沒有任何東西可以讓生命週期腳本政策去拒絕。代理程式 寫下使用這個新相依套件的程式碼,寫一個插入幾百列的測試,然後跑起來。

第一百次插入啟動了第二個 Node 行程。

Checkmarx 在 9 月 17 日公開了這場行動, 研究由 Bruno Dias 進行。 BleepingComputer 在 20 日報導, InfoWorld 在 21 日跟進。 套件名為 indexed-btree,刻意做成讓人誤認為真正的 sorted-btree,而當 npm 在 9 月 3 日把它移除時,它每週的下載量已接近兩百萬。它以惡意的形態上線了大約十一週。

大家才剛蓋好的那道防線

六月時 npm CLI 翻轉了安裝腳本的預設值,而 npm 12 在七月交付了這項改動: 相依套件的 preinstall、install 和 postinstall 除非專案逐一具名允許,否則不再 執行,git 與遠端 URL 相依也一併停止預設解析。那篇報導引用的 JFrog 數字,把這三條路徑 放在前一年所見惡意 npm 套件的約 53%。代理程式的執行環境也採取了同樣的姿態: --ignore-scripts,或是一個在套件管理員看到 tarball 之前就把掛鉤剝掉的代理伺服器。 從移除日往回數十一週,indexed-btree 的第一個惡意版本正落在這波推行的中間。

於是操作者不再使用安裝腳本。載入器住在 BTree.prototype.set 裡,也就是這個函式庫 存在的理由本身。當傳進去的鍵是 100,程式碼就從 extended/sharedLoad.min.js 啟動 一個混淆過的第一階段載入器,作為分離的 Node 行程執行。Checkmarx 的摘要精準指出了 為什麼這很難提前看見:載入器藏在函式庫自己的方法裡,用他們的話說,這是「一種做得很好 的手法,能從標準的污點分析工具底下溜過去」。

看看是什麼把它上膛:一個值為 100 的鍵,出現在一個以保存鍵為目的的函式庫裡。排行榜會 碰到它,分頁索引會碰到它,而一個用迴圈插入列的測試,會在第一百次迭代碰到它。照著 README 說的方式使用這個套件,你就替它上了膛。

安裝時什麼都不跑。首次使用時全部都跑。開發者做的事npm install完全沒有生命週期腳本安裝期檢查全部通過node_modules程式碼就躺在那裡幾分鐘或者好幾週npm test代理程式操練這段碼一列列進入樹裡.set(100, …)觸發點是普通資料分離的 Node 行程載入器接下來做的事採集主機指紋主機名稱、架構、CPU、記憶體、開機時間送往 Slack 與 Telegram去問那份合約一份智慧合約,位於Ethereum Sepolia透過公開 RPC 讀取拆開第二階段X25519 金鑰交換、以 ECDH 共享祕密做 AES酬載存放在合約裡收拾乾淨刪掉那些檔案、把觸發點移除從原型上拿掉這三個對外目的地就是整場行動。把它們拿走,載入器就只是一個在空無一物的機器上醒來的行程。
安裝期的防禦什麼也看不到,因為安裝期什麼都沒發生。載入器就在函式庫自己的 set 方法裡,並在值為 100 的鍵抵達時觸發 — 對一個資料結構函式庫來說,那是再普通不過的資料。它生出一個分離的 Node 行程,把主機資訊送往一個 Slack 頻道和一個 Telegram 對話,接著從 Ethereum Sepolia 上的智慧合約讀出下一個位址,解密合約裡存放的第二階段,然後把自己的檔案和那個觸發點一併刪掉。

十一週,以及一個讀起來沒問題的儲存庫

其餘的手法,瞄準的正是你自己會做的那些檢查。

指揮通道是 Ethereum Sepolia 上的一份智慧合約。載入器從那份合約讀出下一個位址,產生 一組 X25519 金鑰對,從合約取得操作者的公鑰,以 ECDH 導出一把 AES 金鑰,再解密鏈上 存放的兩段密文來組出第二階段。Checkmarx 直接說明了好處:「這項技術比傳統 C2 手法更 能抵抗網域或 IP 的下架,因為每當舊位址被拔掉,智慧合約就充當指向新位址的指標。」這 裡面沒有任何東西能給你一個可以列入封鎖名單的惡意網域。你拿到的是一個公開 RPC 端點和 一個合約位址,而操作者不必碰任何一份已安裝的套件,就能把合約重新指向別處。

這個專案看起來是真的。操作者架了一個 GitHub 儲存庫,帶著看似合理的提交歷史和一個 相稱的開發者帳號,而那個儲存庫裡的程式碼並不包含惡意的部分。惡意的部分只存在於 npm 的 tarball 裡。到 GitHub 上讀原始碼——這正是所有人給的建議——只會確認這個套件沒問題。

年紀也替他們工作。十一週足以讓一個套件在你安裝它的時候已經是個既有相依,完全不像那種 幾小時內就被撤下的遭劫版本。Checkmarx 數出九個行為相同的兄弟套件,光 btree-core 就超過 190 萬次下載,其餘各自都在數十萬之譜,並指出操作者掌握著一個持有 109 ETH 的 錢包。

接著,在第二階段落地之後,載入器刪掉自己的檔案,並把觸發點從原型上拿掉。磁碟上的 套件,又變回一個 b-tree 函式庫的模樣。

執行程式碼就是工作本身

上面每一道防禦,都假設有一個由人決定去執行某樣東西的時刻。代理程式把那個時刻拿走了。

一個編碼代理程式安裝相依套件、對著它寫程式、跑測試套件、讀失敗訊息、修改、再跑一次, 一小時幾十回,而你正在開會,因為這個迴圈就是它全部的價值主張。你不會去讀第一百次 插入。代理程式也比你更有理由去產生會踩中這個觸發點的那種資料:它用迴圈寫測試資料、 用連續 ID 填表、用幾千列做效能測試。如果上膛條件是「有人存入鍵 100」,代理程式會比 一個人更早也更常抵達那裡,而且是在一個握著整個工作階段所握之物的行程裡。

安裝期的防禦覆蓋的是套件抵達的那一刻。你的代理程式每天在那裡花一分鐘,其餘時間都在 執行已經抵達的東西。

載入器需要什麼

把這場行動削到骨頭,它需要兩樣東西:一個能連出去的地方,以及在它醒來的那台機器上 值得拿走的東西。這兩者你都能在它跑起來之前決定,而且兩個決定都不需要認出這個套件。

在開發者的筆電上,兩樣都往攻擊者那邊倒。它送出的偵察資訊——主機名稱、架構、CPU、 記憶體、開機時間——描述的是你網路上的你的機器。它生出的行程以你的身分執行,帶著你的 shell、你的 ~/.git-credentials、你的 GH_TOKEN、你的 ~/.aws、你的 SSH 代理。 它的三個目的地是 slack.com、api.telegram.org 和一個公開的 Ethereum RPC 供應商: 兩個你一半工具本來就在對話的 SaaS 主機,以及一個看起來像基礎設施的區塊鏈端點。在 netflow 紀錄裡,這份清單沒有一項讀起來是惡意的。

Bromure Agentic Coding 在主機上、在代理程式執行的 VM 之外,回答這兩個半邊。

憑證先來,因為它們才是目標。你設定的每一個祕密,在工作區 VM 裡都以保留結構的替身形式 存在(GitHub 權杖是 ghp_ 加 36 個字元,Anthropic 金鑰以 sk-ant-api03-brm- 開頭), 由真實值透過 HKDF-SHA256 與每次安裝專屬的鹽導出,因此 gh、git 和 claude 都會 接受它,而且它在不同工作階段之間看起來從不輪替。真實值加密留在你的 Mac 上,主機端的 代理伺服器在請求離開 VM 之後才把它們替換到線路上,而且只替換給當初鑄造該憑證的那台 主機。手冊把這條不變式寫得很明白:VM 內的任何檔案、環境變數或行程,都不會包含真實的 API 金鑰、OAuth 權杖、AWS 祕密或 SSH 私鑰。一個讀取環境的竊取程式,收集到的是替身。

把替身帶到它不該去的地方,這件事本身就是警報。代理伺服器掃描每一個對外請求,尋找被 送往非鑄造對象主機的假權杖。一旦出現,代理伺服器就以 451 擋下該請求,目的地什麼也 收不到,而 Bromure 會暫停這台 VM。它會強制重新附接已分離的工作階段,把外框染成紅色, 並發出一則警示,指名權杖預覽、憑證、它原本所屬的主機,以及它正要前往的主機。你可以把 工作區關掉,或選擇 Save for Investigation,它會在停止之前匯出系統磁碟、家目錄和 每一個共享資料夾。這個載入器在握手之後就抹掉自己的檔案,所以事後來看的人只會找到一顆 整齊的磁碟。在請求進行到一半時暫停,讓你拿到的是它整理之前的那台機器。

接著是連出去的範圍。護欄為每個工作區帶來一道出口防火牆:一組 pf 風格的有序規則, 套用在 VM 開啟的每一條連線上,可依主機、CIDR、通訊協定、連接埠,以及在網頁規則裡依 個別 HTTP 方法來指定,並有一個 Unmatched traffic 控制項可設為 Allow 或 Deny。把它設成 Deny,列出工作需要的登錄檔與 API,那麼一個伸手去找 Telegram 機器人 API 或 Ethereum RPC 供應商的載入器,就會在線路上撞上拒絕。有兩層在執行它,讓 客體哪一層都繞不過去:虛擬交換器依目的地 IP 與從 DNS 偷看到的主機名稱,在 TCP 與 UDP 上比對規則,代理伺服器則再依 TLS 伺服器名稱與方法比對一次。交換器把 VM 的 80 與 443 埠流量導進代理伺服器本身,所以沒有任何代理變數可供酬載取消設定。每一個判定都會落成 Security Timeline 裡的一列 Firewall,而規則的修改不必重開機就會傳達到執行中的工作 階段。

在開發者的筆電上載入器以你的身分醒來GH_TOKEN · ~/.git-credentials · ~/.aws真實權杖、真實主機名稱、真實網路它送出的指紋描述的是你的 Mac行程與網際網路之間空無一物Slack偵察資訊送達Telegram偵察資訊送達Sepolia RPC第二階段送達三個看起來像普通 SaaS 流量的目的地。然後載入器把自己抹掉。在 Bromure 工作區裡載入器在客體裡醒來ghp_ + 36 字元 — 是替身,不是權杖真實祕密加密留在 Mac 上它送出的指紋描述的是一台 Ubuntu VM代理伺服器是唯一的出口出口防火牆 — 未比對流量:Denyapi.telegram.org:443 tcp — 已封鎖在交換器依 IP 與 DNS 名稱比對,再於代理伺服器依 TLS 名稱與 HTTP 方法比對一次。
同一個載入器,兩種環境。在筆電上,它採集你真實的機器、帶著你真實的權杖執行,並連上三個看起來像普通 SaaS 流量的目的地。在 Bromure 工作區裡,它採集到的是一台 VM,只找得到在上游一文不值、而且一旦被帶離範圍就會觸動入侵偵測的替身憑證,並在往外走的路上撞上一組有序的出口規則,其未比對流量的預設值可以設為 Deny。

日曆搆不到的地方

Bromure 的版本年齡門檻,預設開啟、設在兩天,是為了劫持那個模式而存在的:維護者帳號 遭到入侵,一個下了毒的版本被發布,整件事在幾小時內被發現並撤下。它已經接住過好幾則 這樣的故事。十一週會越過你會設下的任何界線,所以這裡的工作落到其他層身上。

Supply Chain 面板之所以是一疊,正是為此。專為一開始就惡意的套件而造的那一層是套件 過濾:socket.dev 的遭入侵套件封鎖涵蓋被標記為惡意軟體的套件、誤植名稱、可疑的安裝 腳本與混淆過的程式碼,而 Depi 用一個預先審核過的鏡像取代 npm 登錄檔。兩者都在主機端 執行,API 金鑰留在你的 Mac 上、從不匯出到 VM,而且兩者都站在安裝之前而非之後。封鎖會 以 HTTP 451 回傳,npm 會把它的純文字內容逐字印出來,所以代理程式讀得到理由,會繞路 而不是卡住。

沒有任何信譽服務能抓到全部,而一個乾淨跑了十一週的誤植套件,對它們每一個都是難題, 這正是其餘各層賺到自己位置的地方。替身憑證、入侵偵測與出口規則集,決定的是一個套件 安裝之後能碰到什麼,而它們沒有一個需要認出這個套件。它們覆蓋的是還沒有人分類過的 程式碼,包括再過十一週也不會有人分類的程式碼。

假設它在首次使用時發動

封鎖生命週期腳本值得做,而這場行動繞過了它。請為一個在你的測試套件呼叫函式庫時 醒來的酬載做打算。

觸發點是合法資料

鍵值函式庫裡一個值為 100 的鍵。任何輸入允許清單都幫不上忙,而代理程式產生那種 資料的速度比你快。

別在行程裡留下可偷之物

描述一台 VM 的偵察資訊,加上在上游一文不值的權杖,會把一次成功的執行變成一次 白費的執行。

事先決定好目的地

Slack、Telegram 和一個公開的 Ethereum RPC 端點,靠信譽是封鎖不了的。允許清單 不需要認出它們。

封鎖安裝腳本,把大約一半的惡意 npm 套件掃出了棋盤。正因如此,操作者才必須造出這麼 精巧的東西——一個假的儲存庫、一個偽裝成算術的觸發點,以及一條架在區塊鏈上的指揮通道 ——只為了放進一段一年前在 package.json 裡寫四行就能辦到的程式碼。防禦奏效了,攻擊 的價碼也上去了。

底下那個假設卻從來沒動過。你依然把一個相依套件在安裝之後當成安全的,把執行它當成一個 中性的動作。對一個編碼代理程式來說,執行它就是工作,一天幾千次,而且房間裡沒有人。在 那個行程跑起來之前,就決定它能握著什麼、能連到哪裡,那麼你的代理程式的第一百次插入, 最後會是 Security Timeline 裡一列被封鎖的 Firewall 紀錄。