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

他們先把安全的版本撤下來

8 月 20 日,有人把一個被下毒的 arrayref 推上 crates.io,然後在十六秒之內,把它後面那些安全的版本一一撤下。接著,Cargo 自己的警告就指向了唯一還站著的現代版本。惡意載荷藏在建置腳本裡,所以光是對專案做型別檢查就足以執行它。Bromure Agentic Coding 把整場行動變成一道它必輸的碼表題:被下毒的 crate 只活了 86 分鐘,而預設的年齡關卡是兩天。

攻擊者根本不必騙誰去安裝惡意軟體。他讓乾淨的版本消失,剩下的就交給登錄檔自己的升級 建議去完成。

8 月 20 日 07:15:00 UTC,[email protected] 以它真正維護者的帳號出現在 crates.io 上。 二十四秒之後,攻擊者開始 yank 掉 0.3.5 到 0.3.9:十六秒的腳本工作,把它後面的架子清空。

yank 是 crates.io 的召回機制,是維護者發現某個發行版壞掉時會拉的那根桿子。Cargo 對它的 回應正是你會希望的樣子。它警告你鎖定檔裡有個版本已被撤下,而且在重新解析時拒絕挑選被 撤下的版本。所有跟在這種警告後面的建議,都叫你去更新。

於是攻擊者撤掉安全的版本,只留一個現代版本站著,讓「做該做的事」這件事把你送進去。

九十分鐘,從頭到尾

Rust Security Response WG 的公告StepSecurity 重建的時間線 描述了一場攻擊者已經鋪陳兩天的行動。

8 月 18 日 01:25:58,他們在 crates.io 註冊了一個叫 dtolney 的帳號,跟 David Tolnay 只 差一個字母;生態系裡大多數 proc-macro crate 上都掛著那個名字。半小時後,他們發布了一個 乾淨的 [email protected],那是 proc-macro2 的仿冒名,什麼事都不做。那個發行版存在 的目的,只是給這個帳號一段歷史。

真正的那個在五小時後到。[email protected] 在 8 月 20 日 07:11:15 上線,帶著一個惡意 的 build.rs。四分鐘之後,[email protected] 從合法擁有者的帳號送出,裡面只有一項改動: 一行新的相依,指向 proc-macro1 ^1.0.107。Security Response WG 在維護者這件事上很謹慎, 而且謹慎得對。他們「不認為 arrayref 的作者出於惡意行事」,並指出「他的電腦或憑證很可能 已遭入侵」。[email protected] 在 07:34:07 跟上,[email protected] 在 07:37:49。

然後它結束得幾乎跟開始一樣快。Nextron Systems 的研究團隊找到那個惡意 crate 並通報。 crates.io 在 86 分鐘後撤下 arrayref,90 分鐘後撤下 internment,107 分鐘後撤下 append-only-vec,刪掉六個攻擊者持有的 crate,並鎖住那個被入侵的帳號。RustSec 沒有記錄 到任何人曾對著被下毒的版本建置的證據。

九十分鐘是很快的反應。但對任何在那個窗口裡跑過建置的人來說,它依然是事後才到。

兩天的鋪陳,九十分鐘的曝險8月18日 · 01:25註冊「dtolney」帳號與每個 Rust 開發者早已信任的名字只差一個字母01:55 · 乾淨誘餌 1.0.1068月20日 · 07:11:15[email protected]proc-macro2 的仿冒名夾帶惡意的 build.rs沒有人會打出這個名字8月20日 · 07:15:00[email protected]真實帳號,被竊的憑證函式庫原始碼未動只多了一行相依8月20日 · 07:15:24 → 07:15:40 — 十六秒arrayref 0.3.5, 0.3.6, 0.3.7, 0.3.8, 0.3.9 — 全數 yank登錄檔的召回機制被反過來開:Cargo 現在警告你鎖定的版本已被撤下,並在重新解析時拒絕它只剩一個可安裝的現代版本。你執行 cargo update 去清掉那個警告。build.rs 會在 cargo build、cargo check 與 cargo test 時執行 — 函式庫裡沒有任何函式被呼叫過
這條投遞路徑從頭到尾沒碰過開發者的判斷。一個仿冒名的建置期相依,在拉進它的那個 crate 之前四分鐘發布;那個 crate 後面所有安全的版本,在十六秒的連發中全被撤下;而 Cargo 自己對這次撤下的警告,指向了唯一剩下的現代版本。

函式庫裡沒有任何東西需要被呼叫

惡意載荷住在建置腳本裡,而建置腳本會在編譯期以你的權限執行。The Hacker News 報導載荷會 在 cargo buildcargo checkcargo test 時觸發。中間那個是謹慎的選項:你的編輯器存檔時會跑的那個,你想聽聽編譯器意見但什麼都不 想執行時會打的那個。在這裡,它就夠了。

腳本本身一旦攤在你面前,其實又短又不含蓄。它從 base64 片段重組出 https://23.254.165[.]112:9089/,關掉 TLS 憑證驗證,下載一份對應你作業系統與架構的載荷, 然後分離執行。漂亮的一手是 std::mem::forget(child),丟掉那個 handle,好讓行程逃出 Cargo 的 job object。植入物繼續跑,而編譯器以一次綠色的建置結束。

接著下來的是一支資訊竊取程式。 Wiz 的分析 指出它會用 HTTPS POST 對 /49890878 回報,蒐集主機名稱、使用者名稱、作業系統與已安裝 應用程式,並查詢 Chrome、Brave 與 Edge 的 SQLite 登入資料庫裡存下的憑證。它在 Windows 上 以登錄檔 Run 機碼持久化,在 macOS 上用 LaunchAgent,在 Linux 上用 systemd 使用者服務; 若 C2 失聯,就退回一套網域產生演算法,每五天生出十個 .com 網域。Wiz 把這批基礎設施 連到北韓的活動:跟 Mastra npm 行動相同的 /49890878 端點、共用的憑證簽發者,以及反覆 使用的同一段 Hostwinds 位址。

它出貨的建置涵蓋 x86_64 的 Linux、Windows 與 macOS,外加 aarch64 的 macOS。最後那個是 一台 Apple Silicon 筆電。有人想過誰在編譯 Rust。

你從沒選過的那個相依

arrayref 累計有 2 億 4500 萬次下載,而幾乎沒有人把它寫進 Cargo.toml。StepSecurity 數 出 406 個相依的 crate 版本,arrayref 坐在 winit 底下,因此也在 eguiiced 底下, 在 blake3blake2b_simd 底下,還在相當一部分的 Ethereum 與 Solana 工具鏈底下。 internmentappend-only-vec 兩者再加上 1900 萬次下載。

所以「加入相依前先審查」這條建議在這裡構不著。沒有人加入它。它出現在四層之下,在一個你 確實加過的 crate 底下,在一份一滑而過的鎖定檔差異裡。讀函式庫的程式碼也一樣幫不上忙。 三個被下毒的 crate,沒有人動過它們的原始碼;整個改動就是一行相依,指名一個兩天大的 crate,而發布它的帳號名字看起來是對的。

一個講代理式編碼的部落格為什麼在乎

再看一次那個觸發點。建置輸出裡跳出一則 yank 警告。有某個東西跑了 cargo update 把它清掉。

那個東西,如今常常已經不是人。清掉警告是代理在前往你交代的事情途中順手做的家務:它看到 一個被撤下的版本,它知道怎麼修,它就修。它在你睡覺時工作,在迴圈裡工作,橫跨整批待辦。 它因為建置失敗而刷新鎖定檔,因為某個功能需要而加進新的 crate。

一個無人看管在工作的代理,是最可能落在那 86 分鐘窗口裡的角色,也是最不可能為了一個由它 認得的名字所發布、自己卻沒見過的傳遞性相依而停下來的角色。給它一個被入侵的維護者帳號、 一個跟 David Tolnay 只差一個字母的仿冒名,再加上一則建議升級的登錄檔警告,這條鏈上就 沒有任何一環留給判斷去攔。

你還是活得下來,因為判斷本來就是決定這件事的爛地方。

Bromure 把它變成一道碼表題

每一份 Bromure Agentic Coding 設定檔出廠時都開著年齡關卡,設在兩天。主機端的 MITM 代理伺服器看得到 VM 發出的每一次套件抓取,橫跨 npm、PyPI、Cargo、RubyGems、Maven、 NuGet、Go 模組與 Packagist,而比門檻更年輕的版本回不來。

Cargo 拿到的是完整待遇。Bromure 認得 index.crates.iocrates.iostatic.crates.io,然後在途中改寫 crate 的中繼資料:把太新的版本從版本清單裡剔掉,並把 max_versionmax_stable_versionnewest_version 設成通過過濾後最新的那個發行版。 Cargo 的解析器從來不會知道有更新的版本存在。

現在把數字並排放。

每個版本最多活到多老,對上兩天的界線[email protected]arrayref 拉進它時,它只有 4 分鐘大[email protected]在登錄檔上 86 分鐘[email protected]90 分鐘[email protected]107 分鐘Bromure 年齡關卡2 天 — 2,880 分鐘 — 預設開啟,可從 0 調到 90在主機端代理過濾:太新的版本會從 crate 中繼資料裡剔掉,「最新版本」欄位在 Cargo 讀到之前就被改寫。
這場攻擊裡的每一個版本,都在預設政策根本不會打開的那個窗口裡出生又死去。年齡關卡不是對套件做的判斷;它是一只時鐘,而這場行動沒有辦法等它走完而不被發現。

這場行動裡沒有任何東西活到兩天大。arrayref 指名 [email protected] 當相依時,它才 四分鐘大。對關卡後面的解析器來說,這兩個 crate 都不可見,所以 cargo update 找不到任何 新東西。

你會在畫面上看到這件事。0.3.5 到 0.3.9 被 yank、0.3.10 被過濾掉之後,對 arrayref 做一次 全新解析,可能會回來說挑不出版本——那是一則錯誤,而不是一次無聲的成功。已經釘在 0.3.9 的既有 Cargo.lock 會繼續建置,因為 yank 只擋新的解析。無論哪一種,你那個星期四早上都 花在讀一則解析器訊息,而不是輪換你手上所有的憑證。

Bromure 還會再查第二次,為的是解析器早就有答案的情況。它拿每一次成品抓取,也就是 .crate 檔本身,去對照記錄下來的發布時間,並回一個把年齡講明白的 451發布於 41 分鐘前,政策 要求至少 2 天。一份熱的中繼資料快取,買不到攻擊者任何東西。

投放器的第一跳哪裡也去不了

假設你把時鐘關掉了。有人為某個套件開了例外,或是把門檻降低一個下午。建置腳本還是得連上 23.254.165.112 的 9089 埠。

每一份 Bromure 設定檔都帶著自己的出向政策,寫成一小段 pf 風格的規則集:

allow web crates.io
allow web static.crates.io
allow web index.crates.io
allow tcp api.anthropic.com:443
default deny

你的 Mac 在共用同一份規則的兩層上執行它:虛擬交換器,跨所有協定以目的 IP 與從 DNS 側錄 到的主機名稱比對;以及代理伺服器,以 TLS SNI 與 HTTP 方法比對。9089 埠上一個赤裸的 IP, 在那個檔案裡對不上任何規則,於是落到 default deny,連線在邊界的主機這一側就死了。第二 階段的 DGA,每五天十個新的 .com 網域,撞上同一面牆,因為產生出來的名字,仍然是沒有人 放進清單裡的名字。每一次拒絕都會以一列 egress.firewall 落在 Window → Security Timeline,帶著主機、IP、埠與判定,寫在 VM 裡的程式碼構不著的地方。

那裡面沒有東西可偷

讀一讀這起事件附帶的復原清單:輪換 SSH 金鑰、雲端權杖、API 權杖、簽章金鑰。清掉登錄檔 快取。清掉 CI 的快取層。重建 vendor 目錄。在每一台曾在那個窗口裡建置過的機器上。

一個 Bromure 工作區用一個空房間回答了這份清單的大半。

真正的憑證從來沒進過 VM。那個客體裡的 ANTHROPIC_API_KEY 是一個 brm_… 的佔位字串。 kubeconfig 裡放的是用完即丟的用戶端憑證。容器登錄檔的認證是一串衍生出來的 Basic 字串。 GitHub 權杖是假的,SSH 金鑰是為那個工作區逐設定檔產生的 ed25519 金鑰,不是你筆電上那把。 主機端的 MITM 代理會在出向請求上換成真值,而 AWS 又更進一步:主機會用真正的金鑰材料重新 簽署 SigV4 請求,所以任何繞過代理的東西拿回來的是 Amazon 的 InvalidSignatureException, 而不是一次能用的呼叫。

一支在那個客體裡掃憑證的資訊竊取程式,會找到憑證。它們解析得過去,看起來也對,而且一離開 那台機器就一文不值。

它購物清單上的其餘部分,落在 hypervisor 的另一邊。Chrome、Brave 與 Edge 把登入資料庫留在 你的 Mac 上,放在客體沒有路徑可達的使用者設定檔裡。它想寫的那個 macOS LaunchAgent,也就是 它為什麼要出一份 aarch64 macOS 建置的理由,根本沒有 macOS 可寫。它可以在客體自己的家目錄 裡裝一個 systemd 使用者服務,而那一層有一顆按鈕:Erase home… 會把設定檔的 /home/ubuntu 重設回剛複製完的狀態,Reset to base… 則從唯讀的基礎映像重新複製工作區 的系統磁碟。

而且你早就有那份清單了

這起事件每一份修補指南的最後一行都是一次 grep:在每一台機器的每一份 Cargo.lock 裡搜六個 crate 名字和三個版本字串,然後到 ~/.cargo/registry/cache 裡搜那些 tarball。那是很好的 指示,也是很慘的一個下午,而且它只在你還留著的機器上有用。

Bromure 在事情發生的當下就把答案記下來。每一次穿過代理的套件抓取,都會在 Window → Security Timeline 寫下一列 supply_chain.fetch,帶著生態系、套件、版本、 結果,以及結果背後的理由,記在主機這一側,跟每一次憑證替換與每一次防火牆判定並排。等下 一則公告點名某個版本與某個兩小時窗口時,「這裡有東西抓過那個嗎?」就是一次搜尋,而不是一 場遠征。

Bromure Enterprise Manager 把同一道資料流在整個機隊上彙總起來,而問題通常正是以那種形式 抵達:昨晚跑代理的那九十台機器裡,有沒有哪一台建置了它

值得留下的那部分

crates.io 這回表現得好。從通報到移除九十分鐘,六個攻擊者的 crate 消失,被入侵的帳號鎖住, 一則清楚的公告,而且沒有任何人中招的證據。Rust 團隊也特意不去責怪一位憑證被偷的維護者, 那是對的直覺,而且不總是常見的直覺。

不過,總會有人重用這套機制,因為它便宜,而且它聰明。yank 是一根公開、即時、只要一行的 桿子,能讓一個登錄檔去建議升級,而每個生態系都有一根。跑你建置的那個東西會看到那則建議 並照著做,而如今照著做的那個東西,往往缺了整整一個星期四早上的脈絡,不知道哪個 crate 是 哪個。

所以把這個決定放到不需要脈絡的地方。一個版本發布與你的建置之間隔著兩天的日光,不是對某個 套件、某位維護者,或某個只差一個字母的帳號名字所下的判斷。那是一只時鐘,而這場行動只有 九十分鐘。


來源:Rust Security Response WG「Supply chain attack on arrayref」(2026 年 8 月 20 日) · Wiz「Rust Supply Chain Attack on arrayref: Significant Overlap with DPRK Campaigns」(2026 年 8 月 20 日) · StepSecurity「arrayref, internment, and append-only-vec Poisoned by the proc-macro1 Build-Time Dropper」(2026 年 8 月 20 日) · The Hacker News「Rust Supply Chain Attack Puts Build-Time Malware in Crates with 245 Million Downloads」(2026 年 8 月 20 日) · BleepingComputer「Hackers poison arrayref Rust crate to push infostealer malware」(2026 年 8 月 20 日) · Semgrep「Rust crates arrayref & append-only-vec compromised」(2026 年 8 月 20 日)