vatt'ghern jaskier's ballads

一份圖形安裝程式的 3,799 支使用者可執行檔裡,3,790 支會印出同一個訊號——而動手腳的那份 strip,早在攻擊完成之前,就已經離開了這台機器的相依圖。

不用碰編譯器,改 strip 就夠了

Thompson 在 1984 年的〈Reflections on Trusting Trust〉裡示範過一種攻擊:惡意編譯器同時認得「安全敏感的目標程式」與「編譯器自己的原始碼」,對前者植入後門,對後者複製同一套邏輯——原始碼再怎麼審查也審不出問題,因為問題根本不在原始碼裡。四十年來這個模型被當成編譯器專屬的風險。Julien Malka、Aman Sharma、Martin Monperrus、Stefano Zacchiroli 與 Théo Zimmermann 這篇論文,把同一套自我複製邏輯架在一個從不解析、也不產生原始碼的工具上:GNU strip。他們在 NixOS 的 bootstrap 裡動了一份 strip,讓它一路建出完整的圖形安裝程式,過程沒有任何建置失敗。

Thompson 的原始構想需要編譯器同時「認得」兩個東西:一個是要植入後門的目標程式,另一個是編譯器自己的原始碼——辨識與複製都建立在編譯器懂語法這件事上。這篇論文刻意選了一個相反的極端:strip 連一行原始碼都不讀,它只搬動已經編譯好的位元組,卻一樣能完成同一套辨識與複製的動作。要理解這件事怎麼發生,得先弄清楚一個背景設定:任何 Linux 發行版都得先有編譯器、連結器、C 函式庫才能開始建置任何東西,而這些初始工具沒辦法無中生有,只能從一小批不是從源碼建出來的既有二進位檔開始——這批檔案就叫 bootstrap seed,是整個系統裡唯一一處得靠信任、而不是靠可稽核建置去接受的起點。

3,790 支印出訊號的執行檔,和一份早就不在系統裡的 strip

論文選的驗證對象不是玩具專案,而是真實的 nixpkgs revision fef9403a3e4d,bootstrap 用的是 GNU binutils 2.44,架構是 x86_64-linux。他們建出的圖形安裝程式閉包裡有 1,199 個套件輸出、3,799 支使用者可執行的 ELF 檔,總磁碟用量 6.16 GB,套件大小中位數只有 0.39 MB。這 3,799 支裡有 3,791 支能從命令列直接呼叫,其中 3,790 支在正常執行時會印出感染標記。中標的軟體橫跨好幾個語言生態圈:C/C++ 寫的 bash、git、sudo、curl,Python 寫的 pydoc3.13,Rust 寫的 rsvg-convert,Go 寫的 captree,甚至 Lua 直譯器本身——攻擊是在二進位層面下手,跟原始程式語言無關。這些數字不是同一種東西反覆出現:bash 1,327 KB、git 56,313 KB、sudo 248 KB、curl 222 KB、python3.13 10,928 KB 這些常用工具中標之外,連 Python 生態圈裡 pydoc3.13(110 KB)、idle3.13(61 KB)這種周邊小工具,Rust 的 rsvg-convert(8,065 KB)、Go 的 captree(1,964 KB)、Lua 直譯器本身(328 KB),全都在名單上。唯一沒中標記的是 firefox 147.0.3,原因也很乾脆:它的建置腳本自己傳了 --disable-strip--disable-install-strip,整個跳過了 strip phase,不是攻擊漏抓,是這支程式從頭到尾沒被 strip 碰過。

謎題在這裡:這一切的起點,只是 bootstrap 二進位種子裡的一份 strip。NixOS 的 bootstrap 本來就設計成分好幾個階段重建工具鏈,一個階段的產物餵給下一個階段,直到 final stdenv 在執行期不再引用最初的種子——論文原句就是這麼陳述的,沒有多說這個設計的『初衷』是什麼。合理的推測是,這種分階段設計背後的想法,是讓稽核者只要盯住原始碼與最終產物就夠了,反正種子遲早會退出系統的相依圖。論文卻證明,種子退出相依圖,跟後門是不是還在系統裡,是兩件不相干的事:後門已經沿著 strip 的世代寫進了下一代的身體裡,寫下這一切的那份原始檔案,此時早已無關緊要。

論文對「閉包」的定義出現在評估章節:閉包是 bootstrap 完並建出目標套件集之後,所有被建出套件的集合,在這次評估裡就是前面提到的 1,199 個套件輸出、3,799 支使用者可執行 ELF 檔。這裡插一句背景,不是論文原句:Nix 的閉包概念本來就是拿「執行期會用到什麼」當邊界,一個套件的閉包缺一樣就跑不動;照這個邏輯推,bootstrap seed 不在閉包裡,理論上就代表這個套件的執行完全不需要那份種子,種子乾不乾淨當然就無關緊要。這個推論在大多數情況下是對的,論文攻擊的正是這條推論唯一的漏洞:閉包管的是「執行期需要什麼」,不是「這個 binary 的每一個位元組是怎麼被寫出來的」。

這聽起來像是修辭上的巧合,但論文把它寫成了可以復現的工程結果:同一份被動過手腳的 strip,先讓自己活過一次重建,再把手法教給下一版,教完之後自己就可以功成身退。稽核者如果只檢查「最終系統裡還有什麼」,看到的會是一份乾淨的種子清單——因為那份下手的 strip,本來就不在清單上。

桌面版點選任一階段看細節 · 4 個階段

STAGE 0 bootstrap seed 裡的 strip₀ 重建 + FIXUP strip₀ 竄改新版 strip₁ FINAL STDENV 不再引用 seed 完整安裝程式 3,790/3,791 中標 fixup rebuild closure

Stage 0 · 種子已經帶原

strip 活在 bootstrap 的二進位種子裡,而自我複製的第一個條件就是「bootstrap 必須先信任這個工具,而它才會被拿去建出下一版」——strip 剛好一開始就滿足這一條。

重建 + fixup · 竄改真正發生的時刻

build phase 從乾淨原始碼編出新版 strip₁,fixup phase 則叫舊版 strip₀ 去處理剛編出來的一批 binary,包括 strip₁ 自己。strip₀ 偵測到「自己正在以 strip 身分執行」,就在正常動作之前把 payload 植入 strip₁。

Final stdenv · 種子已經離開

NixOS bootstrap 的目標本來就是讓 final stdenv 不再於執行期引用最初的種子。這一步確實達成了——但攻擊也在同一時刻證明,種子離開閉包跟後門是否還在,是兩件事。

完整安裝程式 · 結果攤開

3,799 支使用者可執行檔裡,3,791 支能從命令列呼叫,其中 3,790 支印出感染標記;唯一沒中的 firefox,是因為它自己的建置腳本跳過了 strip phase。

如果你負責稽核一條 build pipeline,看到這裡的第一直覺通常是兩道既有防線:重現建置,以及從源碼開始的 bootstrap。論文一個一個拆開,說明為什麼兩道都攔不住。

假設一:重現建置應該會露出馬腳

可重現建置的邏輯很直覺:同一份 recipe、同一份原始碼,獨立建置兩次,如果兩次輸出 bit-for-bit 一致,就代表建置過程沒有藏著沒交代的變因;論文自己也把這套機制的目的講得很白:讓每次建置都產出 bit-for-bit 相同的輸出,再由一個獨立的第三方重建者去確認某支 binary 跟它宣稱的原始碼相符。Nix 的套件管理模型在這件事上有它的條件——論文的背景說明是:Nix 是一套函式化的套件管理器,套件從函式化語言寫成的「recipe」(也就是 derivation)建出來,derivation 裡精確定義了建置需要的環境,未宣告的相依會被一套 sandbox 機制擋在建置之外。合理的推測是,論文選擇在可重現建置向來受重視的 NixOS 上示範,用意之一就是讓這個結果更有份量:如果連這樣的發行版都擋不住,這道防線的普遍有效性就得重新評估——這個動機論文沒有明講。

但論文點出一個很基本的落差:「Reproducible builds confirm that a binary is deterministic and not that its build environment is honest, so a rebuild that reuses the same seed reproduces the same implant bit-for-bit.」可重現建置驗證的是「這次建置有沒有決定性」,不是「這個建置環境本身乾不乾淨」。只要兩次重建用的都是同一份已經帶後門的二進位種子,兩次理所當然都會得到一模一樣、同樣帶後門的輸出——一致本身從來不是乾淨的證明。配方、相依紀錄、原始碼三者都完好無損,所以既有的兩種稽核機制都不會觸發警訊。

假設二:從源碼開始 bootstrap,應該會把種子縮到沒有意義

另一條防線更狠:乾脆讓 bootstrap 從幾乎不留信任的起點開始。GNU Guix 的 full-source bootstrap 就是這個路線的代表,把二進位種子壓縮到只剩幾百位元組,剩下整條工具鏈全部從可稽核的原始碼重建。這聽起來應該能把 trusting-trust 攻擊的立足點徹底鏟掉,畢竟編譯器本身幾乎沒有藏身空間了。同一個邏輯也常被用來替 diverse double-compiling 背書,論文自己的定義是:用一個獨立的第二個編譯器重新編譯同一個編譯器,再檢查兩次的結果是否一致。這麼做的直覺——不是論文原句,是這套手法本身的假設——是乾淨的原始碼餵進乾淨的編譯器,理論上該收斂到同一個答案,如果兩次編譯結果不一致,就代表其中一個編譯器動過手腳。這個手法確實能抓到 Thompson 原始構想裡的那種攻擊,前提是比較的兩邊真的用了不同的編譯器。Guix 的 full-source bootstrap 值得多說一句:它把整條工具鏈往前推到幾乎只剩機器碼等級的起點,再一路用可稽核的原始碼重新長出編譯器、組譯器、連結器。這個工程量非常可觀,也確實把「相信一個編譯器二進位檔」這件事的必要性壓到最低。論文完全沒有否定這份工作的價值,只是指出它解決的是編譯器這一層,沒有涵蓋到 fixup 階段的其他工具。

論文的判決是:這兩個假設都只對編譯器成立。「A utility such as strip sustains the attack whenever a tampered instance participates in the rebuild, even after the compiler seed shrinks to a few hundred bytes.」即使編譯器種子已經縮到幾百位元組,只要 fixup 階段裡還有一份被竄改的 strip 參與重建,攻擊照樣能延續。作者的結論很直白:full-source bootstrap 要真正拔掉這個載體,必須連 post-build 工具一起從原始碼重建,不能只顧編譯器。diverse double-compiling 也一樣失效——它只多樣化編譯器,不會多樣化 strip 這類工具,被竄改的 strip 同時站在比較的兩邊,差異因此被互相抵銷。

真正在起作用的,是三個條件同時成立

兩道防線都落空之後,論文回到最基本的問題:自我複製到底需要什麼。答案是三個條件同時成立——bootstrap 必須先信任這個工具(C1)、舊世代必須把新一代寫出來(C2)、被改過的後繼者必須接手同一個角色繼續執行同一件事(C3)。作者把第三條關係正式命名為 successor edge,並直接點名:正是這條邊在世代之間搬運 payload。strip 剛好三個條件都中:它活在 bootstrap 的二進位種子裡,每次重建工具鏈時都會被叫去對新編出來的 binary 做 fixup,之後每個階段又都會再叫一次 strip 來做同一件事。

三個條件放在一起看,共同點很清楚:沒有一條要求這個工具懂原始碼在寫什麼。C1 只問這個工具有沒有資格先被相信,C2 只問它有沒有機會去動下一份,C3 只問下一份會不會接著做同一件事。編譯器符合這三條,是因為它剛好也負責看得懂原始碼這件事,但那件事本身從來不是自我複製的必要條件——這正是論文標題想戳破的認知。

指到或點選底線詞看 strip 為何符合每一條 · 3 個條件

C1 · 先被信任 the bootstrap must trust the utility before it builds the utility from source, and strip ships in the bootstrap seed. —— strip 一開始就活在 bootstrap 的二進位種子裡。C2 · 舊世代寫下一代 an older generation must write into the representation of a newer one, and strip rewrites a binary during the fixup phase. —— strip 在 fixup phase 就是負責改寫剛編出來的 binary。C3 · 新世代接手同一角色 the modified successor must later take over the same role, and every later stage runs strip again for fixup. —— 每個後續階段都會再叫一次 strip 去做同一件事。 ——三條一次到齊,缺一個都不會自我複製。

具體怎麼動手腳?竄改本身只靠三個 ELF 層級的手法:在檔案尾端附加新的位元組(append),把載入器執行時用不到的 PT_NOTE 欄位挪用成可執行區段(repurpose),再改動少數 ELF header 欄位讓載入器與其他工具「看得到」新附加的內容(redirect)。整個過程不解析一行原始碼,也不動編譯器。payload 本身很小,以不依賴任何函式庫的 freestanding program 形式執行:process 啟動時,核心先進入被植入的進入點而不是原本的進入點,payload 做完該做的事之後,再跳回原始進入點,不 fork、也不取代整個 process——對外表現跟一支正常的 strip 幾乎沒有差別。payload 選擇不依賴任何函式庫,不是可有可無的技術細節,而是必要條件。論文給的理由是:同一份 payload 位元組會被原封不動植入到相依關係彼此無關的各種執行檔裡,所以它不能指望這些宿主執行檔會提供任何東西——freestanding 讓 payload 完全自帶邏輯,不管落在哪一支相依關係完全不同的 binary 裡,都能維持一樣的行為,這也是為什麼同一份 payload 能夠原封不動,從 strip₀ 一路搬進 final stdenv 的 strip 裡。選 strip 當示範對象也不是隨機挑的:論文給的理由是,strip 直接處理已經編譯完成的 ELF 檔案,因此能碰到每一種語言、每一條工具鏈的產出,不像 gcc 這類編譯器只影響它自己能編譯的那些語言寫出來的程式;strip 也照慣例被授予寫入套件可執行輸出的權限。這兩點合起來,才是它能悄悄扛起整條 trusting-trust 鏈的原因。

拖動中線比較 strip₁ 竄改前後的 ELF 結構

乾淨的 strip₁(編譯剛完成) ELF header PT_NOTE 備用,未使用 .text / .dynsym 等 檔案在此結束 entry → .text(正常路徑) 竄改後的 strip₁(fixup 之後) ELF header 欄位被改(redirect) PT_NOTE → 執行區段(repurpose) .text / .dynsym 等 +payload append 出來的位元組 entry → payload → 跳回 .text

規模攤開來看,代價是零

作者還特地跑了圖形安裝程式對應的 NixOS functional tests——session VM 裡包括 gnome-shell、mutter、nautilus、GNOME 控制中心在內的每一支 binary 都是被動過手腳的 strip 建出來的,而且沒有任何測試失敗。攻擊完全不影響可用性,也不會被既有測試覆蓋抓出來。對一個平常靠 CI 綠燈判斷「這次建置沒問題」的團隊來說,這是最讓人不安的一句話:綠燈本身從來就不是為了抓這種攻擊而設計的。以下是這次示範的規模,攤開來看:

指標數值
評估用 nixpkgs revisionfef9403a3e4d
套件輸出(package outputs)1,199
使用者可執行 ELF 檔3,799
可從命令列直接呼叫3,791
印出感染標記3,790
唯一沒中的執行檔firefox 147.0.3
closure 總磁碟用量6.16 GB
套件大小中位數0.39 MB
functional test 失敗數0

作者自己劃的界線,比想像中誠實

但作者也主動劃了界線。目前的 injector 只處理 little-endian、帶 spare PT_NOTE、ET_EXEC 或有直譯器的 ET_DYN 這類 ELF 檔;共享函式庫、其他架構、沒有直譯器的 static PIE、沒有合適 note segment 的檔案都不在範圍內——作者自己說「沒有技術上的不可能」去擴大範圍,只是為了這次研究示範刻意維持在這個複雜度。這個具體實作也不見得能原封不動搬到其他發行版,取決於各家 bootstrap 流程與是否預設使用 strip;但自我複製這個抽象模式比實作更通用,只要建置圖裡存在 successor edge,就適用。合理的推測是,把這些限制放回稽核脈絡裡看,結論不該是「這個攻擊還不成熟,先別緊張」,而是「這次刻意示範的版本,比真正想長期潛伏的版本容易抓得多」——一個只在特定 ELF 形狀上動手、還留著明顯訊號的 prototype 都能不動聲色地感染整條圖形安裝程式,拿掉這些刻意留下的破綻之後,難度合理推斷只會往下降,不會往上升(論文本身只說 prototype 是刻意做得容易偵測,沒有做這個推斷)。

更值得留意的是作者自己承認的一件事:這個 prototype 刻意留了明顯標記——可見的訊號、.payload 這個區段名稱、比對 basename 的檢查——方便研究人員自己觀察,不是自我複製模式本身需要的條件。論文也刻意讓攻擊傳播到分發版裡的每一個套件,只是為了測試傳播範圍能有多廣。作者自己接著寫:「in practice, nothing prevent an attacker to make the attack mostly dormant and only propagate to future builds of strip, until it reaches its ultimate target, maybe years after the initial propagation」——這段是推測,不是已經示範過的行為,但它說明了「容易偵測」只是這次示範刻意留下的選擇,不是這個攻擊模式天生的弱點。作者的結論寫得很直接:「We have shown that the trusting-trust attack is not limited to compilers.」——trusting-trust 不限於編譯器,是這篇論文從頭到尾要證明的一件事。

除了 strip,還有哪些工具具備同樣的三個條件

除了 strip,還有哪些工具具備同樣的三個條件 strip 論文完整實作並驗證的攻擊本體 patchelf 作者主張同樣滿足 C1/C2/C3,未做完整實作 install / cp 作者僅稱「plausibly」自我傳播,證據強度最弱

點選任一列看驗證強度差異

strip · 已完整驗證

論文實際建構、實作並在真實 nixpkgs revision(fef9403a3e4d)上跑通整條攻擊鏈,還做了功能測試確認不影響可用性。

patchelf · 作者主張滿足三條件

作者指出「strip and patchelf satisfy all three through the builder's fixup phase」,是分析性主張,沒有像 strip 一樣完整實作驗證。

install / cp · 保留較大不確定性

作者原句是「install and cp plausibly self-propagate through make install」——「plausibly」是三者中最弱的用字,同樣沒有實作驗證。

從攻擊者的角度看,這整套後門的成本小得不成比例:三個 ELF 層級的操作、一份不需要函式庫的小 payload,換來的是一整個發行版幾乎所有二進位檔案的控制權。相對地,防禦方要補上這個缺口,得把稽核範圍從「這個編譯器乾不乾淨」擴大到「bootstrap 裡每一個會讀寫已編譯 binary 的工具乾不乾淨」——範圍擴大了一整層,但論文示範的攻擊手法本身,複雜度並沒有跟著同步上升。對維護內部 build pipeline 的工程師來說,這篇論文給出的具體檢查清單並不複雜:先問自己的 bootstrap 或 CI 有沒有一個被信任但不是每次都從源碼重建的二進位檔,再問這個二進位檔有沒有機會在建置流程裡改寫別的 binary,最後問被改寫的東西會不會在未來又被叫去做同一件事。三個問題都答「有」的工具,就值得單獨拉出來稽核,不必等到有人先示範一次完整的攻擊才開始在意。

Take-away:稽核 supply chain 不能只盯編譯器的來源與建置可重現性;只要一個 post-build 工具同時滿足「先被信任、能寫下一代、下一代接手同一角色」這三個條件,它就足以扛起整條 trusting-trust 鏈。bootstrap 稽核的範圍,得延伸到 fixup phase 裡每一個會動 binary 的工具——strip、patchelf、install、cp,都算。