Go 部落格

完美可復現、經過驗證的 Go 工具鏈

Russ Cox
2023 年 8 月 28 日

開源軟體的一個關鍵好處是任何人都可以閱讀原始碼並檢查其功能。然而,大多數軟體(即使是開源軟體)都以編譯後的二進位制檔案的形式下載,而這些二進位制檔案更難檢查。如果攻擊者想對開源專案進行供應鏈攻擊,最隱蔽的方法就是替換正在提供的二進位制檔案,同時不修改原始碼。

解決此類攻擊的最佳方法是使開源軟體構建可復現,這意味著使用相同的原始碼進行的構建,每次執行時都會產生相同的輸出。這樣,任何人都可以透過從真實原始碼構建並檢查重新構建的二進位制檔案是否與提供的二進位制檔案逐位相同,來驗證提供的二進位制檔案是否沒有隱藏的更改。這種方法證明二進位制檔案沒有後門或其他未包含在原始碼中的更改,而無需反彙編或檢視其內部。由於任何人都可以驗證二進位制檔案,因此獨立團體可以輕鬆檢測和報告供應鏈攻擊。

隨著供應鏈安全變得越來越重要,可復現的構建也變得越來越重要,因為它們提供了一種簡單的方法來驗證開源專案的提供二進位制檔案。

Go 1.21.0 是第一個具有完美可復現構建的 Go 工具鏈。早期的工具鏈是可能復現的,但需要付出巨大的努力,而且可能沒有人去做:他們只是相信在go.dev/dl 上提供的二進位制檔案是正確的。現在,“信任但驗證”變得很容易了。

本文解釋了構建可復現性所涉及的內容,審查了我們為使 Go 工具鏈可復現而對 Go 所做的許多更改,然後透過驗證 Go 1.21.0 的 Ubuntu 包來演示可復現性的一項優勢。

使構建可復現

計算機通常是確定性的,所以您可能會認為所有構建都是同樣可復現的。這只是從某個角度來看。我們將資訊稱為相關輸入,當構建的輸出可能取決於該輸入時。如果一個構建可以與所有相同的相關輸入重複執行,那麼它就是可復現的。不幸的是,許多構建工具最終會包含我們通常不會意識到其相關性,並且可能難以重新建立或作為輸入提供的輸入。我們將輸入稱為非預期輸入,當它被證明是相關的,但我們並未打算如此。

構建系統中最重要的非預期輸入是當前時間。如果構建將可執行檔案寫入磁碟,檔案系統會將當前時間記錄為可執行檔案的修改時間。然後,如果構建使用“tar”或“zip”等工具打包該檔案,修改時間就會被寫入存檔。我們肯定不希望我們的構建基於當前時間而改變,但它確實會。因此,當前時間就成為構建的非預期輸入。更糟糕的是,大多數程式不允許您將當前時間作為輸入提供,因此無法重複此構建。為了解決這個問題,我們可以將建立的檔案的時間戳設定為 Unix 時間 0 或從構建的原始檔中讀取的特定時間。這樣,當前時間就不再是構建的相關輸入了。

構建的常見相關輸入包括

  • 要構建的原始碼的特定版本;
  • 將包含在構建中的依賴項的特定版本;
  • 執行構建的作業系統,它可能會影響結果二進位制檔案中的路徑名;
  • 構建系統上的 CPU 架構,它可能會影響編譯器使用的最佳化或某些資料結構的佈局;
  • 正在使用的編譯器版本以及傳遞給它的編譯器選項,它們會影響程式碼的編譯方式;
  • 包含原始碼的目錄名稱,它可能出現在除錯資訊中;
  • 執行構建的帳戶的使用者名稱、組名、uid 和 gid,它們可能出現在存檔中的檔案元資料中;
  • 等等。

為了實現可復現的構建,每個相關輸入都必須在構建中可配置,然後二進位制檔案必須與明確列出所有相關輸入的配置一起釋出。如果您做到了這一點,您就擁有了一個可復現的構建。恭喜!

然而,我們還沒有完成。如果只有在找到具有正確架構的計算機、安裝特定的作業系統版本、編譯器版本、將原始碼放在正確的目錄中、正確設定使用者身份等之後才能重現二進位制檔案,那麼這在實踐中可能太過繁瑣,以至於沒有人願意去做。

我們希望構建不僅是可復現的,而且是易於復現的。為此,我們需要識別相關輸入,然後,而不是記錄它們,消除它們。構建顯然必須依賴於正在構建的原始碼,但其他所有內容都可以消除。當構建的唯一相關輸入是其原始碼時,我們稱之為完美可復現

Go 的完美可復現構建

截至 Go 1.21,Go 工具鏈是完美可復現的:其唯一的相關輸入是該構建的原始碼。我們可以在 Linux/x86-64 主機、Windows/ARM64 主機、FreeBSD/386 主機或任何其他支援 Go 的主機上構建特定的工具鏈(例如,Go for Linux/x86-64),並且我們可以使用任何 Go 引導編譯器,包括一直追溯到 Go 1.4 的 C 實現,並且我們可以更改任何其他細節。這些都不會改變構建的工具鏈。如果我們從相同的工具鏈原始碼開始,我們將獲得完全相同的工具鏈二進位制檔案。

這種完美的復現性是自 Go 1.10 以來一直努力的成果,儘管大部分工作集中在 Go 1.20 和 Go 1.21。本節重點介紹我們消除的一些最有趣的相關輸入。

Go 1.10 中的復現性

Go 1.10 引入了一個內容感知的構建快取,它根據構建輸入的指紋而不是檔案修改時間來決定目標是否是最新的。由於工具鏈本身就是這些構建輸入之一,而且 Go 是用 Go 編寫的,因此引導過程只有在單臺機器上的工具鏈構建是可復現的情況下才會收斂。整個工具鏈的構建過程如下:

我們首先使用早期的 Go 版本(引導工具鏈,Go 1.10 使用 C 語言編寫的 Go 1.4;Go 1.21 使用 Go 1.17)構建當前 Go 工具鏈的原始碼。這將生成“toolchain1”,然後我們使用它再次構建所有內容,生成“toolchain2”,然後我們使用它再次構建所有內容,生成“toolchain3”。

Toolchain1 和 toolchain2 是從相同的原始碼構建的,但使用了不同的 Go 實現(編譯器和庫),因此它們的二進位制檔案肯定會不同。然而,如果兩個 Go 實現都沒有 bug 且是正確的實現,那麼 toolchain1 和 toolchain2 應該表現完全相同。特別是,當遇到 Go 1.X 原始碼時,toolchain1 的輸出(toolchain2)和 toolchain2 的輸出(toolchain3)應該完全相同,這意味著 toolchain2 和 toolchain3 應該相同。

至少,這就是想法。在實踐中使這一點成立需要消除幾個非預期輸入

隨機性。對映迭代和在帶有鎖的序列化中執行多個 goroutine 的工作都會在生成結果的順序上引入隨機性。這種隨機性可能導致工具鏈每次執行時都產生幾個不同可能輸出中的一個。為了使構建可復現,我們必須找到所有這些並對相關專案列表進行排序,然後再用它來生成輸出。

引導庫。編譯器使用的任何庫,如果可以在多個不同的正確輸出之間進行選擇,則其輸出可能會在一對 Go 版本之間發生變化。如果該庫輸出的變化導致編譯器輸出的變化,那麼 toolchain1 和 toolchain2 將不會在語義上相同,toolchain2 和 toolchain3 也不會逐位相同。

典型示例是sort 包,它可以將相等元素的順序任意排列。暫存器分配器可能會進行排序以優先使用常用變數,連結器會按大小對資料段中的符號進行排序。為了完全消除排序演算法的影響,使用的比較函式必須從不報告兩個不同的元素相等。實際上,這個不變數對於工具鏈中所有 sort 的使用來說都過於繁重,因此我們改為將 Go 1.X sort 包複製到提供給引導編譯器的源樹中。這樣,編譯器在使用引導工具鏈時就使用了與自行構建時相同的排序演算法。

我們還必須複製的另一個包是compress/zlib,因為連結器會寫入壓縮的除錯資訊,而壓縮庫的最佳化可能會改變確切的輸出。隨著時間的推移,我們也將其他包新增到了該列表中。這種方法還有一個額外的好處,即允許 Go 1.X 編譯器立即使用這些包中新增的新 API,但缺點是這些包必須能夠用舊版本的 Go 進行編譯。

Go 1.20 中的復現性

Go 1.20 的工作透過從工具鏈構建中刪除兩個更相關的輸入,為易於復現的構建和工具鏈管理做好了準備。

主機 C 工具鏈。一些 Go 包,尤其是 net,在大多數作業系統上預設使用 cgo。在某些情況下,例如 macOS 和 Windows,使用 cgo 呼叫系統 DLL 是解析主機名的唯一可靠方法。但是,當我們使用 cgo 時,我們會呼叫主機 C 工具鏈(即特定的 C 編譯器和 C 庫),不同的工具鏈具有不同的編譯演算法和庫程式碼,從而產生不同的輸出。cgo 包的構建圖如下所示:

因此,主機 C 工具鏈是隨工具鏈一起提供的預編譯 net.a 的相關輸入。對於 Go 1.20,我們決定透過從工具鏈中刪除 net.a 來解決這個問題。也就是說,Go 1.20 不再提供預編譯的包來填充構建快取。現在,程式第一次使用 net 包時,Go 工具鏈會使用本地系統的 C 工具鏈進行編譯並快取該結果。除了從工具鏈構建中刪除相關輸入並減小工具鏈下載量外,不提供預編譯包還能使工具鏈下載更具可移植性。如果我們用一個 C 工具鏈在一個系統上構建 net 包,然後在另一個系統上用另一個 C 工具鏈編譯程式的其他部分,一般來說,不能保證這兩部分可以連結在一起。

我們最初提供預編譯的 net 包的原因之一是允許在沒有安裝 C 工具鏈的系統上構建使用 net 包的程式。如果沒有預編譯的包,這些系統會發生什麼?答案因作業系統而異,但在所有情況下,我們都安排 Go 工具鏈在沒有主機 C 工具鏈的情況下繼續良好地構建純 Go 程式。

  • 在 macOS 上,我們使用 cgo 將使用的底層機制重寫了 net 包,而沒有實際的 C 程式碼。這避免了呼叫主機 C 工具鏈,但仍然生成引用所需系統 DLL 的二進位制檔案。這種方法之所以可行,僅僅是因為每個 Mac 都安裝了相同的動態庫。使非 cgo 的 macOS net 包使用系統 DLL 也意味著交叉編譯的 macOS 可執行檔案現在使用系統 DLL 進行網路訪問,從而解決了長期存在的特性請求。

  • 在 Windows 上,net 包已經直接使用了 DLL,沒有 C 程式碼,所以無需更改。

  • 在 Unix 系統上,我們不能假定特定的 DLL 介面到網路程式碼,但純 Go 版本對於使用典型 IP 和 DNS 設定的系統來說工作良好。此外,在 Unix 系統上安裝 C 工具鏈比在 macOS 和尤其是 Windows 上容易得多。我們更改了 go 命令,使其根據系統是否安裝了 C 工具鏈自動啟用或停用 cgo。沒有 C 工具鏈的 Unix 系統將回退到純 Go 版本的 net 包,在極少數情況下,這還不夠好的話,他們可以安裝 C 工具鏈。

在放棄了預編譯的包之後,Go 工具鏈中唯一仍然依賴於主機 C 工具鏈的部分是使用 net 包構建的二進位制檔案,特別是 go 命令。隨著 macOS 的改進,現在可以透過停用 cgo 來構建這些命令,從而完全消除了主機 C 工具鏈作為輸入,但我們將其最後一步留給了 Go 1.21。

主機動態連結器。當程式在動態連結 C 庫的系統上使用 cgo 時,生成的二進位制檔案包含指向系統動態連結器的路徑,例如 /lib64/ld-linux-x86-64.so.2。如果路徑錯誤,二進位制檔案將無法執行。通常,每個作業系統/架構組合都有一個正確的路徑。不幸的是,像 Alpine Linux 這樣的 musl-based Linux 發行版使用與 Ubuntu 這樣的 glibc-based Linux 發行版不同的動態連結器。為了讓 Go 在 Alpine Linux 上執行,Go 引導過程如下:

載入程式 cmd/dist 檢查本地系統的動態連結器,並將該值寫入與連結器其他部分一起編譯的新原始檔,從而有效地將該預設值硬編碼到連結器本身。然後,當連結器從一組編譯的包構建程式時,它將使用該預設值。結果是,在 Alpine 上構建的 Go 工具鏈與在 Ubuntu 上構建的工具鏈不同:主機配置是工具鏈構建的相關輸入。這是一個復現性問題,也是一個可移植性問題:在 Alpine 上構建的 Go 工具鏈在 Ubuntu 上無法構建工作二進位制檔案,甚至無法執行,反之亦然。

對於 Go 1.20,我們採取了一項措施來解決復現性問題,方法是更改連結器,使其在執行時諮詢主機配置,而不是在工具鏈構建時將預設值硬編碼。

這解決了 Alpine Linux 上鍊接器二進位制檔案的可移植性問題,但並未解決整個工具鏈,因為 go 命令仍然使用 net 包,因此使用 cgo,因此其二進位制檔案中包含動態連結器引用。就像在上一節中一樣,停用 cgo 編譯 go 命令可以解決此問題,但我們將其更改留給了 Go 1.21。(我們認為在 Go 1.20 的週期中沒有足夠的時間來正確測試此類更改。)

Go 1.21 中的復現性

對於 Go 1.21,完美復現性的目標已近在眼前,我們處理了剩餘的、主要是小的相關輸入。

主機 C 工具鏈和動態連結器。如上所述,Go 1.20 在從工具鏈構建中刪除主機 C 工具鏈和動態連結器方面取得了重要進展。Go 1.21 透過停用 cgo 構建工具鏈,完成了這些相關輸入的刪除。這還提高了工具鏈的可移植性:Go 1.21 是第一個標準 Go 工具鏈在 Alpine Linux 系統上無需修改即可執行的 Go 版本。

刪除這些相關輸入使得從不同系統交叉編譯 Go 工具鏈而沒有任何功能損失成為可能。反過來,這提高了 Go 工具鏈的供應鏈安全性:我們現在可以使用受信任的 Linux/x86-64 系統為所有目標系統構建 Go 工具鏈,而不是為每個目標系統安排單獨的受信任系統。因此,Go 1.21 是第一個包含在 go.dev/dl/ 上提供的所有系統的二進位制檔案的版本。

原始碼目錄。 Go 程式在執行時和除錯元資料中包含完整路徑,因此當程式崩潰或在偵錯程式中執行時,堆疊跟蹤會包含原始碼檔案的完整路徑,而不僅僅是未指定目錄中檔案的名稱。不幸的是,包含完整路徑會使儲存原始碼的目錄成為構建的相關輸入。為了解決這個問題,Go 1.21 更改了釋出的工具鏈構建,使用 go install -trimpath 來安裝編譯器等命令,它將源目錄替換為程式碼的模組路徑。如果釋出的編譯器崩潰,堆疊跟蹤將列印類似 cmd/compile/main.go 的路徑,而不是 /home/user/go/src/cmd/compile/main.go。由於完整路徑會引用不同機器上的目錄,因此這種重寫不會造成損失。另一方面,對於非釋出版本,我們保留完整路徑,以便當從事編譯器本身開發的開發人員導致其崩潰時,IDE 和其他讀取崩潰的工具可以輕鬆找到正確的原始檔。

主機作業系統。 Windows 系統上的路徑使用反斜槓分隔,例如 cmd\compile\main.go。其他系統使用正斜槓,例如 cmd/compile/main.go。儘管早期版本的 Go 已將大部分路徑規範化為使用正斜槓,但出現了一個不一致之處,導致 Windows 上的工具鏈構建略有不同。我們發現並修復了該錯誤。

主機架構。 Go 執行在各種 ARM 系統上,並可以使用軟體浮點數學庫(SWFP)或硬體浮點指令(HWFP)發出程式碼。預設情況下選擇一種模式的工具鏈必然會有所不同。就像我們之前在動態連結器中看到的,Go 引導過程檢查了構建系統,以確保生成的工具鏈在該系統上執行。出於歷史原因,規則是“除非構建是在具有浮點硬體的 ARM 系統上執行,否則假定為 SWFP”,而交叉編譯的工具鏈則假定為 SWFP。如今,絕大多數 ARM 系統都具有浮點硬體,這在原生編譯和交叉編譯的工具鏈之間造成了不必要的差異,而且還有一個額外的曲折:Windows ARM 構建始終假定為 HWFP,這使得決定依賴於作業系統。我們將規則更改為“除非構建是在沒有浮點硬體的 ARM 系統上執行,否則假定為 HWFP”。這樣,交叉編譯和在現代 ARM 系統上的構建將產生相同的工具鏈。

打包邏輯。建立我們為下載釋出的實際工具鏈存檔的所有程式碼都位於一個單獨的 Git 儲存庫 golang.org/x/build 中,而存檔的打包方式的具體細節會隨著時間而改變。如果您想重現這些存檔,則需要擁有該儲存庫的正確版本。我們透過將打包存檔的程式碼移動到 Go 主源樹(作為 cmd/distpack)來刪除了這個相關輸入。截至 Go 1.21,如果您擁有給定版本 Go 的原始碼,您也擁有打包存檔的原始碼。golang.org/x/build 儲存庫不再是相關輸入。

使用者 ID。我們釋出的下載的 tar 存檔是從寫入檔案系統的分發版構建的,並且使用tar.FileInfoHeader將檔案系統中的使用者和組 ID 複製到 tar 檔案中,從而使執行構建的使用者成為相關輸入。我們更改了存檔程式碼以清除這些。

當前時間。與使用者 ID 類似,我們釋出的下載的 tar 和 zip 存檔是透過將檔案系統修改時間複製到存檔中來構建的,從而使當前時間成為相關輸入。我們可以清除時間,但我們認為使用 Unix 或 MS-DOS 零時間會顯得令人驚訝,甚至可能破壞某些工具。相反,我們更改了儲存在儲存庫中的 go/VERSION 檔案,以新增與該版本相關的時間。

$ cat go1.21.0/VERSION
go1.21.0
time 2023-08-04T20:14:06Z
$

打包器現在在將檔案寫入存檔時從 VERSION 檔案複製時間,而不是複製本地檔案的修改時間。

加密簽名金鑰。 macOS 的 Go 工具鏈除非我們用 Apple 批准的簽名金鑰簽名二進位制檔案,否則無法在終端使用者系統上執行。我們使用內部系統用 Google 的簽名金鑰進行簽名,顯然我們無法共享該私鑰以允許其他人重現簽名後的二進位制檔案。相反,我們編寫了一個驗證器,可以檢查兩個二進位制檔案除了簽名外是否相同。

特定於作業系統的打包程式。我們使用 Xcode 工具 pkgbuildproductbuild 來建立可下載的 macOS PKG 安裝程式,並使用 WiX 來建立可下載的 Windows MSI 安裝程式。我們不希望驗證者需要這些工具的完全相同的版本,因此我們採用了與加密簽名金鑰相同的方法,編寫了一個驗證器,可以檢視包內部並檢查工具鏈檔案是否完全符合預期。

驗證 Go 工具鏈

僅一次使 Go 工具鏈可復現是不夠的。我們希望確保它們保持可復現,並希望確保其他人能夠輕鬆地重現它們。

為了讓我們自己保持誠實,我們現在在受信任的 Linux/x86-64 系統和 Windows/x86-64 系統上構建所有 Go 發行版。除了架構之外,這兩個系統幾乎沒有任何共同之處。這兩個系統必須產生逐位相同的存檔,否則我們將不繼續釋出。

為了讓其他人能夠驗證我們的誠實,我們編寫併發布了一個驗證器,golang.org/x/build/cmd/gorebuild。該程式將從我們 Git 儲存庫中的原始碼開始,並重新構建當前 Go 版本,檢查它們是否與在 go.dev/dl 上釋出的存檔匹配。大多數存檔需要逐位匹配。如上所述,有三個例外情況使用更寬鬆的檢查。

  • macOS tar.gz 檔案預計會不同,但驗證器會比較其中的內容。重新構建的副本和釋出的副本必須包含相同的檔案,並且所有檔案必須完全匹配,可執行二進位制檔案除外。可執行二進位制檔案在剝離程式碼簽名後必須完全匹配。

  • macOS PKG 安裝程式不會重新構建。相反,驗證器會讀取 PKG 安裝程式中的檔案,並檢查它們是否與 macOS tar.gz 完全匹配,同樣在剝離程式碼簽名後。從長遠來看,PKG 的建立足夠簡單,可以將其新增到 cmd/distpack 中,但驗證器仍然必須解析 PKG 檔案才能執行忽略簽名程式碼的可執行比較。

  • Windows MSI 安裝程式不會重新構建。相反,驗證器會呼叫 Linux 程式 msiextract 來提取其中的檔案,並檢查它們是否與重新構建的 Windows zip 檔案完全匹配。從長遠來看,MSI 建立可能會新增到 cmd/distpack 中,然後驗證器可以使用逐位 MSI 比較。

我們每天夜間執行 gorebuild,並將結果釋出在 go.dev/rebuild,當然其他人也可以執行它。

驗證 Ubuntu 的 Go 工具鏈

Go 工具鏈易於復現的構建應該意味著在 go.dev 上提供的工具鏈二進位制檔案與其他打包系統中的二進位制檔案匹配,即使那些打包者是從原始碼構建的。即使打包者使用了不同的配置或其他更改進行編譯,易於復現的構建也應該仍然可以輕鬆地重現他們的二進位制檔案。為了演示這一點,讓我們重現 Ubuntu 的 golang-1.21 包版本 1.21.0-1(針對 Linux/x86-64)。

首先,我們需要下載並解壓 Ubuntu 包,它們是ar(1) 存檔,其中包含 zstd 壓縮的 tar 存檔。

$ mkdir deb
$ cd deb
$ curl -LO http://mirrors.kernel.org/ubuntu/pool/main/g/golang-1.21/golang-1.21-src_1.21.0-1_all.deb
$ ar xv golang-1.21-src_1.21.0-1_all.deb
x - debian-binary
x - control.tar.zst
x - data.tar.zst
$ unzstd < data.tar.zst | tar xv
...
x ./usr/share/go-1.21/src/archive/tar/common.go
x ./usr/share/go-1.21/src/archive/tar/example_test.go
x ./usr/share/go-1.21/src/archive/tar/format.go
x ./usr/share/go-1.21/src/archive/tar/fuzz_test.go
...
$

這是原始碼存檔。現在是 amd64 二進位制存檔。

$ rm -f debian-binary *.zst
$ curl -LO http://mirrors.kernel.org/ubuntu/pool/main/g/golang-1.21/golang-1.21-go_1.21.0-1_amd64.deb
$ ar xv golang-1.21-src_1.21.0-1_all.deb
x - debian-binary
x - control.tar.zst
x - data.tar.zst
$ unzstd < data.tar.zst | tar xv | grep -v '/$'
...
x ./usr/lib/go-1.21/bin/go
x ./usr/lib/go-1.21/bin/gofmt
x ./usr/lib/go-1.21/go.env
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/addr2line
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/asm
x ./usr/lib/go-1.21/pkg/tool/linux_amd64/buildid
...
$

Ubuntu 將正常的 Go 樹分成兩半,分別位於 /usr/share/go-1.21 和 /usr/lib/go-1.21。讓我們把它們放回一起。

$ mkdir go-ubuntu
$ cp -R usr/share/go-1.21/* usr/lib/go-1.21/* go-ubuntu
cp: cannot overwrite directory go-ubuntu/api with non-directory usr/lib/go-1.21/api
cp: cannot overwrite directory go-ubuntu/misc with non-directory usr/lib/go-1.21/misc
cp: cannot overwrite directory go-ubuntu/pkg/include with non-directory usr/lib/go-1.21/pkg/include
cp: cannot overwrite directory go-ubuntu/src with non-directory usr/lib/go-1.21/src
cp: cannot overwrite directory go-ubuntu/test with non-directory usr/lib/go-1.21/test
$

錯誤是關於複製符號連結的抱怨,我們可以忽略。

現在我們需要下載並解壓上游 Go 原始碼。

$ curl -LO https://go.googlesource.com/go/+archive/refs/tags/go1.21.0.tar.gz
$ mkdir go-clean
$ cd go-clean
$ curl -L https://go.googlesource.com/go/+archive/refs/tags/go1.21.0.tar.gz | tar xzv
...
x src/archive/tar/common.go
x src/archive/tar/example_test.go
x src/archive/tar/format.go
x src/archive/tar/fuzz_test.go
...
$

為了跳過一些試錯,事實證明 Ubuntu 使用 GO386=softfloat 構建 Go,這在為 32 位 x86 編譯時強制使用軟體浮點,並對生成的 ELF 二進位制檔案進行剝離(刪除符號表)。讓我們從 GO386=softfloat 構建開始。

$ cd src
$ GOOS=linux GO386=softfloat ./make.bash -distpack
Building Go cmd/dist using /Users/rsc/sdk/go1.17.13. (go1.17.13 darwin/amd64)
Building Go toolchain1 using /Users/rsc/sdk/go1.17.13.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
Building Go toolchain2 using go_bootstrap and Go toolchain1.
Building Go toolchain3 using go_bootstrap and Go toolchain2.
Building commands for host, darwin/amd64.
Building packages and commands for target, linux/amd64.
Packaging archives for linux/amd64.
distpack: 818d46ede85682dd go1.21.0.src.tar.gz
distpack: 4fcd8651d084a03d go1.21.0.linux-amd64.tar.gz
distpack: eab8ed80024f444f v0.0.1-go1.21.0.linux-amd64.zip
distpack: 58528cce1848ddf4 v0.0.1-go1.21.0.linux-amd64.mod
distpack: d8da1f27296edea4 v0.0.1-go1.21.0.linux-amd64.info
---
Installed Go for linux/amd64 in /Users/rsc/deb/go-clean
Installed commands in /Users/rsc/deb/go-clean/bin
*** You need to add /Users/rsc/deb/go-clean/bin to your PATH.
$

這會在 pkg/distpack/go1.21.0.linux-amd64.tar.gz 中留下標準包。讓我們解壓它並剝離二進位制檔案以匹配 Ubuntu。

$ cd ../..
$ tar xzvf go-clean/pkg/distpack/go1.21.0.linux-amd64.tar.gz
x go/CONTRIBUTING.md
x go/LICENSE
x go/PATENTS
x go/README.md
x go/SECURITY.md
x go/VERSION
...
$ elfstrip go/bin/* go/pkg/tool/linux_amd64/*
$

現在我們可以比較我們在 Mac 上建立的 Go 工具鏈與 Ubuntu 提供的 Go 工具鏈。

$ diff -r go go-ubuntu
Only in go: CONTRIBUTING.md
Only in go: LICENSE
Only in go: PATENTS
Only in go: README.md
Only in go: SECURITY.md
Only in go: codereview.cfg
Only in go: doc
Only in go: lib
Binary files go/misc/chrome/gophertool/gopher.png and go-ubuntu/misc/chrome/gophertool/gopher.png differ
Only in go-ubuntu/pkg/tool/linux_amd64: dist
Only in go-ubuntu/pkg/tool/linux_amd64: distpack
Only in go/src: all.rc
Only in go/src: clean.rc
Only in go/src: make.rc
Only in go/src: run.rc
diff -r go/src/syscall/mksyscall.pl go-ubuntu/src/syscall/mksyscall.pl
1c1
< #!/usr/bin/env perl
---
> #! /usr/bin/perl
...
$

我們已成功重現了 Ubuntu 包的可執行檔案,並確定了剩餘更改的完整集。

  • 各種元資料和支援檔案已被刪除。
  • gopher.png 檔案已修改。仔細檢查後,兩者除了嵌入的 Ubuntu 更新的時間戳外完全相同。也許 Ubuntu 的打包指令碼使用了一個工具重新壓縮了 png,該工具在無法改進現有壓縮的情況下重寫了時間戳。
  • 在引導過程中構建的 distdistpack 二進位制檔案(標準存檔中未包含)已包含在 Ubuntu 包中。
  • Plan 9 構建指令碼(*.rc)已被刪除,但 Windows 構建指令碼(*.bat)仍然保留。
  • mksyscall.pl 和其他七個 Perl 指令碼(此處未顯示)的標題已更改。

特別請注意,我們已經逐位重建了工具鏈二進位制檔案:它們根本沒有出現在 diff 中。也就是說,我們證明了 Ubuntu Go 二進位制檔案與上游 Go 原始碼完全對應。

更好的是,我們在根本不使用任何 Ubuntu 軟體的情況下證明了這一點:這些命令是在 Mac 上執行的,而unzstdelfstrip都是簡短的 Go 程式。一個複雜的攻擊者可能會透過更改打包工具將惡意程式碼插入 Ubuntu 包。如果他們這樣做了,使用這些惡意工具從乾淨的原始碼重現 Go Ubuntu 包仍然會產生惡意包的逐位相同副本。這種攻擊對於這種重建來說是隱形的,就像Ken Thompson 的編譯器攻擊一樣。完全不使用 Ubuntu 軟體來驗證 Ubuntu 包是一種更強大的檢查。Go 的完美可復現構建,它不依賴於主機作業系統、主機架構和主機 C 工具鏈等非預期細節,正是使得這種更強大的檢查成為可能。

(作為歷史記錄的題外話,Ken Thompson 曾告訴我,他的攻擊實際上被檢測到了,因為編譯器構建不再可復現。它有一個 bug:編譯器後門中新增的字串常量處理不當,每次編譯器編譯自身時都會增加一個 NUL 位元組。最終有人注意到不可復現的構建,並嘗試透過編譯成彙編來查詢原因。編譯器後門沒有自身複製到彙編輸出中,因此組裝該輸出會刪除後門。)

結論

可復現的構建是加強開源供應鏈的重要工具。像SLSA這樣的框架專注於來源和軟體保管鏈,可用於指導信任決策。可復現的構建透過提供一種驗證信任是否恰當的方法來補充這種方法。

完美的復現性(當原始碼是構建的唯一相關輸入時)只有對於自構建的程式(如編譯器工具鏈)才是可能的。這是一個崇高但有價值的目標,正是因為自託管編譯器工具鏈本身就很難驗證。Go 的完美復現性意味著,假設打包者不修改原始碼,每個 Go 1.21.0 的 Linux/x86-64(請替換為您喜歡的系統)的重新打包,無論以何種形式分發,都應該分發完全相同的二進位制檔案,即使它們都是從原始碼構建的。我們已經看到,對於 Ubuntu Linux 來說,情況並非完全如此,但完美復現性仍然允許我們使用一個非常不同的、非 Ubuntu 的系統來重現 Ubuntu 的打包。

理想情況下,所有以二進位制形式分發的開源軟體都應該具有易於復現的構建。在實踐中,正如我們在本文中所見,非預期輸入很容易滲入構建。對於不需要 cgo 的 Go 程式,可復現的構建與使用 CGO_ENABLED=0 go build -trimpath 編譯一樣簡單。停用 cgo 會將主機 C 工具鏈作為相關輸入刪除,而 -trimpath 會刪除當前目錄。如果您的程式需要 cgo,您需要在執行 go build 之前安排一個特定的主機 C 工具鏈版本,例如透過在特定的虛擬機器或容器映象中執行構建。

除了 Go 之外,Reproducible Builds 專案旨在提高所有開源軟體的復現性,是瞭解如何使您自己的軟體構建可復現的良好起點。

下一篇文章:Go 1.21 中的配置檔案引導最佳化
上一篇文章:使用 slog 進行結構化日誌記錄
部落格索引