8 月 20 日 07:15 UTC,Rust 官方收到通報:一個叫 proc-macro1 的新套件帶著惡意酬載,而老牌工具 crate arrayref 剛好發布的新版本,Cargo.toml 裡多了一行對它的相依宣告。86 分鐘後這個版本從 crates.io 消失——但那 86 分鐘裡,任何一次 cargo build 都足以讓酬載落地一次。
熱門 crate 被掉包,後門只上架 86 分鐘
這篇按時間軸拆這次 arrayref 供應鏈事件:一個平常的相依宣告怎麼變成 build time 寫入磁碟並執行的酬載、Windows 上那個酬載怎麼在 cargo build 結束後還活著、以及 Rust Security Response WG 用了 86 分鐘把它下架之後,官方判斷跟給的建議分別是什麼。材料來自 Rust 官方部落格的事件回顧,跟 safedep 對酬載本身的逆向分析——兩份材料各自負責不同的環節,合起來才是完整的鏈。先說規模:這次攻擊沒有動任何一支 .rs 檔案,全部發生在 Cargo.toml 的一行相依宣告跟 build script 裡,對只看應用程式碼變更的人來說幾乎不可見。
一個平常的 crate,一次不對勁的重新發佈
arrayref 是 Rust 生態裡的一個小工具 crate,safedep 的描述是「a small crate of four macros」,累計下載量約 2.45 億次——不是什麼冷門角落的東西。8 月 20 日 07:15 UTC,Rust Security Response WG 收到通報:「On 2026-08-20 at 7:15 UTC we got a report that the proc-macro1 crate was malicious.」——proc-macro1 是一個沒人眼熟的新套件,名字挨在 Rust 生態裡常見的底層套件 proc-macro2 旁邊,合理的推測是這種命名讓一般開發者掃過 Cargo.lock 時,很容易把它當成手滑打錯,而不是刻意植入的惡意套件。它也不是憑空寫出來的:這個套件的 src/ 「is proc-macro2 with a mechanical find-and-replace of proc-macro2 to proc-macro1」,連 metadata 裡的 repository 網址都是假的,safedep 的說法是它「forges an identity」——外觀合法,內容是複製貼上再改名,讓它在功能上也表現得像真的能用。原始碼是整包搬過來的:任何人真的把 proc-macro1 拉進專案、實際呼叫它的 API,它大概率能正常運作,跟 proc-macro2 表現一致,因為底層程式邏輯本來就是機械式找換名字得來的。
這次通報的起點不是 crates.io 自動掃出來的。事件回顧的結尾特別致謝:「We'd like to thank the Research Team at Nextron Systems GmbH for initially discovering this and reporting it to us.」外部威脅情報團隊先找到、通報,Rust Security Response WG 才在 07:15 UTC 收到消息、正式展開處理。
同一段時間窗口裡,三個既有、被廣泛使用的 crate——arrayref、internment、append-only-vec——各自發布了新版本。有 manifest 層級證據可以直接對照的是 arrayref:它的新版本 Cargo.toml 裡被加進了一行對 proc-macro1 的相依宣告;另外兩個 crate,來源的說法是同樣受到波及,沒有逐一寫明是不是完全相同的改動方式。這不是這幾個 crate 的作者臨時想用一個 token 解析工具:官方事後給的判斷很克制——「We do not believe the author of arrayref to be acting maliciously, but their computer or credentials are likely compromised, and we are attempting to contact them.」用詞是「likely」,不是已經證實的結論,聯繫本人的動作在事件回顧發出時都還在進行。三個獨立 crate 一起被動手腳,指向的是同一組帳號或裝置同時掌握著三份發布權限——crates.io 上的信任邊界劃在「帳號」這一層,一組憑證被拿走,牽連的是這個帳號名下所有東西,不會只影響其中一個 crate。而在 crates.io 的一般認知裡,已經發布出去的版本不會被回頭覆寫——所以動手腳的方式不是去改既有的 0.3.9,而是切出一個新版本號,讓下游在下次 cargo update 或從零建置時自動撿走。這也是為什麼這次攻擊是以「發一個新版本」的形式出現的。
關鍵在 Cargo 的 build script 機制:Cargo 只要讀到 [dependencies.proc-macro1] 這一行、鎖定版本 1.0.107,就會把 proc-macro1 抓下來建置,而它的 build.rs 是在 build time 執行的——按 safedep 的說法,光是編譯一個拉進了壞版本的專案,就足以觸發這段程式碼,不管 arrayref 的原始碼有沒有真的呼叫這個套件裡的任何東西。宣告相依本身就是攻擊鏈的起點,不需要 arrayref 自己的一行程式碼被改動。這也是為什麼「這是一個信任已久的 crate」這句話在供應鏈攻擊裡幾乎沒有防禦力。這個做法還把攻擊面壓縮到最小:不牽動 arrayref 本身任何一支 .rs 檔案,只改了 Cargo.toml 裡的一行。合理的推測是,這種 manifest-only 的改動,比起源碼層級的修改,更容易被只看程式碼 diff 的人工審查略過。這也是這次事件跟一般 typosquat 攻擊的關鍵差異:常見的 typosquat 靠的是誘騙開發者主動打錯字、去裝一個從沒用過的新套件;這次不需要騙任何人做選擇——arrayref 已經在無數專案的 Cargo.lock 裡,開發者要做的事只有「照常升級版本」,攻擊就跟著升級一起進來了。
下面這個時間軸把三個被動過手腳的版本各自的曝光窗口攤開。預設畫面是播完的完整狀態——三條軸各自標了上線與下架時刻;按播放會照原始節奏(壓縮後)重跑一次。
按播放看曝光窗口逐步展開 · 3 個套件版本
把三條軸疊在一起看,07:37 到 08:41 這段——大約 63 分鐘——是三個惡意版本同時掛在 crates.io 上的重疊區間:append-only-vec 07:37 上線時,arrayref 已經在架上超過 20 分鐘,internment 也已經上線約 3 分鐘。這段重疊窗口裡的任何一次 cargo build,撞上這三個名字裡的任何一個都算數,不需要剛好挑中同一秒。
從一行 Cargo.toml 到酬載寫入磁碟並執行
build.rs 內部不直接把 C2 位址寫進原始碼,而是用一串 base64 片段組裝出來,躲開對套件原始碼的靜態字串掃描——safedep 的分析把組裝出來的結果寫得很直接:payload 下載走 23.254.165.112:9089,實際的 C2 通訊走 23.254.165.112:443。拆成片段、執行時才組回去,對的是這樣一種威脅模型:任何在發布前掃過套件原始碼、找可疑 IP 或網域字串的自動化工具,都不會在原始碼裡直接讀到這個 IP——它是 build script 跑起來之後才拼出來的。位址組出來之後,下一步是讓連線看起來合法:build script 自己實作了一個 rustls 憑證驗證器,程式碼是:
fn verify_server_cert(/* ... */) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
不管連線對面丟出什麼憑證——包括在裸 IP 上自簽的憑證——TLS 握手都會判定「有效」。這意味著攻擊者不需要拿到任何真實網域的憑證,也不用管憑證是否快過期,只要控制那個裸 IP,連線就永遠被判定合法。這一步靠的是三個宣告在 build-dependency 裡的套件:base64、rustls、ureq,分別負責解碼、TLS 操作、HTTP client。對一個號稱處理 token 解析的套件來說,這三個相依套件顯得不尋常——safedep 自己的用詞就是 unusual:真正該做的事只有 tokenize,一個 tokenizer 為什麼需要 TLS 跟 HTTP client,說不通。這其實也是防守方能抓到的訊號:一個 build-dependency 清單裡出現網路與加密相關套件,卻對不上這個 crate 應該做的事,在跑 cargo tree --build 這類檢查時就該被標記出來,不需要等到 build script 真的執行。下面這個可以拖動的節點,把宣告相依到酬載寫入磁碟並執行的五個動作串起來,拖到哪一步、下方的說明就換成哪一步。
拖動節點看攻擊鏈每一步在做什麼 · 5 個階段
[dependencies.proc-macro1],版本鎖定 1.0.107。這行本身就是攻擊的起點——不需要 arrayref 的任何一行原始碼被改動。
五個動作串起來看:宣告相依、build.rs 啟動、組出 C2、偽造憑證、寫入磁碟並執行——每一步都疊在前一步建立的信任基礎上,宣告相依本身看起來無害,正是因為它只負責觸發,不負責做壞事;真正的壞事發生在後面三步,而後面三步能發生,靠的正是第一步沒有被攔下來。
拿到位址、驗證形同虛設之後,酬載本身依平台分岔成四種。Unix 系統上的作法比較簡單——直接寫檔、chmod、執行、detach 就結束了;Windows 走了完全不同、明顯更費工的路線,合理的推測是這反映的正是兩種作業系統在「行程脫離父行程」這件事上的落差:Unix 的 fork/exec 語意本來就容易讓子行程獨立於父行程存活,Windows 的 job object 卻預設會把子行程綁死在建立它的行程群組裡,攻擊者得額外繞一手 ShellExecute 才能達到跟 Unix 上一樣的效果。下面把四個目標平台跟各自對應的二進位、執行方式排在一起——滑過或點選平台名稱看細節。
build 腳本鎖定四個目標:Linux x86_64二進位 rust-crate_0.1.0。寫入 /tmp/rust-setup、chmod 執行、標準流導向 null、detach。、Windows x86_64二進位 rust-crate_0.2.0。落地為 PowerShell 腳本,經 VBScript 啟動器以 WScript.exe 執行。、macOS x86_64二進位 rust-crate_0.3.0。歸在 Unix 分支,執行方式同 Linux。、macOS ARM64二進位 rust-crate_0.4.0。同樣歸在 Unix 分支,執行方式同 Linux。——四個都對應各自的預建二進位,遇到不在名單內的平台,build 直接 panic 中止。
四個分支的實作細節不同,目標卻是同一個:讓酬載在 build.rs 執行結束之後,仍然獨立於整個 build 流程繼續存在。Unix 上靠的是 fork/exec 天生的行程獨立性,Windows 上就得靠額外一層 ShellExecute 才能達到同樣的效果——下面拆開看 Windows 這條路徑實際怎麼繞過限制。
Windows 上,酬載怎麼活過 cargo build 的結束
Unix 分支的路徑很直白:直接 spawn 子行程、標準流導向 null、detach,酬載跟 cargo build 之間沒有結構性的牽制。Windows 上不一樣——如果直接 spawn 子行程,這個子行程會被 Windows 綁進 cargo build 所在的 job object。Job object 是 Windows 用來管理一組相關行程生命週期的機制:把子行程綁進同一個 job,父行程(也就是 cargo build)就會被子行程的狀態卡住——只要子行程還沒結束,job 就還沒結束,父行程也得等。攻擊者的 build.rs 原始碼裡留了一句解釋性註解,safedep 直接引了出來:「ShellExecute via WScript escapes Cargo's job object; spawned children otherwise keep the build script (and cargo build) waiting until they exit.」——透過 WScript 的 ShellExecute 啟動酬載,這個子行程就不再落在 Cargo 的 job object 邊界裡面,也就不再讓 cargo build 卡著等它結束。最後一步是把 Rust 端對這個子行程的控制權主動放掉:「The final std::mem::forget leaks the wscript child handle so its destructor never runs. Together this detaches the payload from Cargo.」build.rs 跑完、cargo build 結束,這個行程仍然在背景活著,不會因為建置流程結束而被清掉。
build.rs 對平台的判斷也不是漫無邊際的:四個目標之外的平台,build 直接 panic 中止,不會嘗試繼續執行或留下殘缺的酬載。對一個名義上只該做 token 解析的 build script 來說,內建這種按平台切換行為、且對不支援的平台主動中止的邏輯,本身就是一個不尋常的失敗模式。
std::mem::forget 這個動作背後的機制是這樣的:Rust 的所有權系統原本會在一個值離開作用域時自動呼叫它的解構子,行程 handle 的解構子通常負責等待子行程結束、回收資源;forget 的作用是告訴編譯器「不要幫我做這件事」,直接放棄追蹤這個值的生命週期。用在正常程式碼裡,這是個危險但合法的逃生門;用在這裡,效果是讓 Rust 端徹底不再管這個子行程死活——不等它、不清理它,酬載也就不會因為 build.rs 函式返回而被連帶回收。
86 分鐘之後,官方怎麼判斷、又建議做什麼
三個被動過手腳的版本,各自的下架時間點並不一樣:arrayref 0.3.10 07:15 上線、08:41 下架,上架 86 分鐘;internment 0.8.7 07:34 上線、09:04 下架,上架 90 分鐘;append-only-vec 0.1.9 07:37 上線、09:25 下架,上架 107 分鐘。三個數字不一樣:時間差對應的是逐一確認、逐一撤下的節奏,每多花的那幾十分鐘,就是多留在架上被拉進某次 cargo build 的機會。在 86 到 107 分鐘這個尺度下,任何當時沒有鎖定確切版本號、又剛好觸發一次全新的建置環境——本機開發、CI pipeline、發布腳本——理論上都足以讓 build script 跑一次;跟一般惡意軟體需要使用者主動下載、點開執行檔比起來,這種攻擊面完全長在建置流程裡,不需要任何人手動做錯什麼。除了這三個既有 crate 的惡意版本,這次事件牽連的還有一整批全新的 typosquat 套件:proc-macro1、proc-macro-en、aovine、arone、aronenao、tinymember,全部被整包從 crates.io 刪除,不只是移除某個特定版本——合理的推測是,這幾個名字本身就是惡意帳號直接建立的,沒有無辜的舊版本需要留。
下架的動作也不只是刪掉新版本這麼單純。官方在事件回顧裡另外提到一句:「We have removed the malicious version and unyanked the maliciously-yanked versions.」——被入侵的帳號在發布惡意版本的同時,還把原本乾淨的舊版本標成 yanked(yank 不會刪除版本,但會讓新的相依解析跳過它)。回應團隊除了刪掉新發布的惡意版本,還得把這些被動過手腳的 yank 狀態一一還原,讓原本乾淨的版本重新可以被正常解析到。這代表清理的範圍比「移除一個壞版本」更寬:要同時處理「新增的東西」跟「被隱藏起來的舊東西」。
對「作者是不是共犯」這個問題,官方的說法從頭到尾維持在推測而非定論的層次:「their computer or credentials are likely compromised」,用的是「likely」,沒有把帳號被盜寫成已核實的事實,聯繫本人的動作也還在進行中。給讀者的建議從一句概括開始:「We recommend you check your local dependencies to ensure these crates were not pulled in.」後面還接著一段更具體的做法——拿 find 指令走一遍 ~/.cargo/registry/cache,就能快速確認這些 crate 有沒有在本機被用過。safedep 的技術分析額外附上了雜湊值方便核對——arrayref 0.3.10 是 25ad700976873c76af785cb99b33c48db7df8b81f21d1e9e06b3676b9a9373ae,proc-macro1 1.0.107 是 61198155da51b838772eecf5bfaac6cbc4dcc388dccc56658fc28a8e831b34d4。事件回顧的結尾另外列出六位參與應變的工程師名字,加上最先通報的 Nextron Systems GmbH 研究團隊——實際擋下這次事件的,是這組外部通報接上六位工程師的人工核實;從公開資訊看,過程裡看不出有自動化系統參與偵測,官方只點名了最先發現並通報的那個外部團隊。
九個套件分成兩種類型,處置邏輯也不一樣:合理的推測是,六個全新成立的套件本身就是惡意帳號的產物,沒有乾淨版本需要保留,因此整包移除;三個既有 crate 只有特定的新版本被動了手腳,官方選擇的是精準移除那個版本,而不是連帶下架整個 crate 過去所有版本——對還在用這三個 crate 舊版本的下游專案來說,這個處置方式意味著只要沒有升級到剛好那個版本,其實不受影響。下面這張表把九個套件、各自的類型跟處置方式排在一起——點欄名可以排序。
| 套件 | 類型 | 處置 |
|---|---|---|
| proc-macro1 | 全新 typosquat(冒充 proc-macro2) | 整包刪除 |
| proc-macro-en | 全新套件 | 整包刪除 |
| aovine | 全新套件 | 整包刪除 |
| arone | 全新套件 | 整包刪除 |
| aronenao | 全新套件 | 整包刪除 |
| tinymember | 全新套件 | 整包刪除 |
| arrayref 0.3.10 | 既有信任套件被加惡意相依 | 該版本刪除(86 分鐘) |
| internment 0.8.7 | 既有信任套件被加惡意相依 | 該版本刪除(90 分鐘) |
| append-only-vec 0.1.9 | 既有信任套件被加惡意相依 | 該版本刪除(107 分鐘) |
把三個環節放回一起看:帳號或裝置層級的入侵,讓一個「看起來眼熟」的相依宣告混進了信任已久的 crate;Cargo 的 build script 機制讓這行宣告本身就足以在 build time 跑起一段任意程式碼——光是編譯一次就會觸發,跟 crate 自己的原始碼寫得乾不乾淨無關;而 86 到 107 分鐘的存活窗口,靠的是有人提前送出通報、官方逐一核實下架加上還原被動過手腳的 yank 狀態;從公開資訊看,這一段裡看不出有自動化防線參與。這三層各自的防禦手段是分開的:帳號層級要多因素驗證跟簽章,build script 層級要沙箱化或至少能被靜態掃出「宣告了跟功能不符的 build-dependency」,而發現到下架之間的窗口,短不過人力核實的速度。「沙箱化」在這裡不是抽象口號——具體而言是讓 build script 預設不能碰網路、不能寫使用者家目錄以外的路徑,要用到這些能力得在 manifest 裡顯式宣告,讓「這個 build script 要連網路」這件事本身變成一個審查點——現在的預設是全開,缺的正是這一個開關。三個既有信任的 crate 名字,跟六個從沒出現過的新名字,在這次事件裡走的是同一條路徑——差別只在於前者原本就有理由被放進 Cargo.lock 而不會被多看一眼。從發現到下架,整條時間軸壓縮在兩個多小時裡:07:15 收到通報、07:15 到 07:37 之間三個惡意版本陸續上線、08:41 到 09:25 之間逐一被撤下。速度來自外部通報,加上一組已經知道彼此職責的應變流程。這條流程本身是一種防線,位置卡在「發現之後」:惡意版本已經在架上活了 86 到 107 分鐘,才被人工核實逐一撤下。
具體能做的事,比想像中窄,但都是可以現在就動手的:翻一次 Cargo.lock,找有沒有九個名字裡的任何一個——proc-macro1、proc-macro-en、aovine、arone、aronenao、tinymember,或是 arrayref 0.3.10、internment 0.8.7、append-only-vec 0.1.9 這三個特定版本;往後任何一次日常的版本升級,如果 diff 裡新增了跟這個 crate 宣稱功能對不上的 build-dependency,把它當成需要先問一句「為什麼」的訊號,而不是照舊 cargo update 過去。如果手上維護的專案本身也在 crates.io 上發佈東西,這次事件也是一個提醒:保護發布憑證的優先順序,不該低於保護生產環境的 API key——後者洩漏影響的是自己的系統,前者洩漏影響的是所有信任這個帳號名字的下游。
該做的事:定期跑 cargo tree 或 cargo audit 看有沒有拉進這批已被刪除的套件名稱,不要只看一個 crate 過去用了多久、下載量多高就當作安全訊號——這次事件裡,被利用的正是這份信任本身。