Go 部落格
可完全重現、可驗證的 Go 工具鏈
開源軟體的主要優勢之一是任何人都可以閱讀原始碼並檢查其功能。然而,大多數軟體(即使是開源軟體)都是以編譯後的二進位制形式下載的,這使得檢查變得困難得多。如果攻擊者想要對開源專案發動供應鏈攻擊,最不顯眼的方法就是替換提供的二進位制檔案,同時保持原始碼不變。
解決這種攻擊的最佳方法是使開源軟體的構建過程可重現,這意味著從相同原始碼開始的構建,每次執行時都會產生相同的輸出。這樣,任何人都可以透過從真實的原始碼進行構建,並檢查重新構建的二進位制檔案是否與釋出的二進位制檔案完全一致(bit-for-bit identical),從而驗證釋出的二進位制檔案不含隱藏的更改。這種方法證明二進位制檔案中沒有後門或其他原始碼中不存在的更改,而無需對其進行反彙編或內部檢查。由於任何人都可以驗證這些二進位制檔案,獨立組織可以輕鬆檢測和報告供應鏈攻擊。
隨著供應鏈安全變得越來越重要,可重現構建也變得同樣重要,因為它們為驗證開源專案的釋出二進位制檔案提供了一種簡單的方法。
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 的主機上構建一個特定的工具鏈(例如,針對 Linux/x86-64 的 Go),並且我們可以使用任何 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 使用 Go 1.4,用 C 編寫;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 應該是相同的。
至少,想法是這樣。在實踐中實現這一點需要移除一些無意輸入
隨機性。 Map 迭代以及在多個使用鎖序列化的 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 上,我們重寫了 net 包,使用 cgo 會使用的底層機制,沒有任何實際的 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。(我們認為 Go 1.20 週期沒有足夠的時間來充分測試此類更改。)
主機動態連結器。 當程式在使用動態連結 C 庫的系統上使用 cgo
時,生成的二進位制檔案包含系統動態連結器的路徑,例如 /lib64/ld-linux-x86-64.so.2
。如果路徑錯誤,二進位制檔案將無法執行。通常,每個作業系統/架構組合都有一個正確的路徑。不幸的是,基於 musl 的 Linux 發行版(如 Alpine Linux)使用的動態連結器與基於 glibc 的 Linux 發行版(如 Ubuntu)不同。為了讓 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 系統上 unmodified 執行的 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 上構建的工具鏈略有不同。我們找到了並修復了這個 bug。
主機架構。 Go 執行在各種 ARM 系統上,可以使用軟體庫進行浮點運算 (SWFP) 或使用硬體浮點指令 (HWFP) 生成程式碼。預設使用其中一種模式的工具鏈必然會不同。正如我們之前在動態連結器中看到的那樣,Go 引導過程會檢查構建系統以確保生成的工具鏈在該系統上工作。出於歷史原因,規則是“假定 SWFP,除非構建在具有浮點硬體的 ARM 系統上執行”,交叉編譯的工具鏈假定 SWFP。如今絕大多數 ARM 系統確實擁有浮點硬體,因此這在本地編譯和交叉編譯的工具鏈之間引入了不必要的差異;更麻煩的是,Windows ARM 構建總是假定 HWFP,這使得決策依賴於作業系統。我們將規則改為“假定 HWFP,除非構建在沒有浮點硬體的 ARM 系統上執行”。這樣,在現代 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 工具 pkgbuild
和 productbuild
建立可下載的 macOS PKG 安裝程式,並使用 WiX 建立可下載的 Windows MSI 安裝程式。我們不希望驗證者需要完全相同版本的這些工具,所以我們採取了與加密簽名金鑰相同的方法,編寫了一個驗證器,可以檢視包內部並檢查工具鏈檔案是否完全符合預期。
驗證 Go 工具鏈
一次性使 Go 工具鏈可重現是不夠的。我們希望確保它們始終保持可重現,並且希望確保其他人可以輕鬆重現它們。
為了保持誠實,我們現在在可信的 Linux/x86-64 系統和 Windows/x86-64 系統上構建所有 Go 分發版本。除了架構之外,這兩個系統幾乎沒有任何共同之處。這兩個系統必須生成完全相同的(bit-for-bit identical)歸檔檔案,否則我們不會進行釋出。
為了讓其他人驗證我們是否誠實,我們編寫併發布了一個驗證器,golang.org/x/build/cmd/gorebuild
。該程式將從我們 Git 倉庫中的原始碼開始,重新構建當前 Go 版本,並檢查它們是否與釋出在 go.dev/dl 上的歸檔檔案匹配。大多數歸檔檔案要求完全匹配(bit-for-bit)。如上所述,有三個例外情況使用了更寬鬆的檢查
-
macOS 的 tar.gz 檔案預計會有所不同,但隨後驗證器會比較其中的內容。重新構建和釋出的副本必須包含相同的檔案,並且所有檔案都必須完全匹配,可執行二進位制檔案除外。可執行二進位制檔案在剝離程式碼簽名後必須完全匹配。
-
macOS 的 PKG 安裝程式不會重新構建。相反,驗證器會讀取 PKG 安裝程式內部的檔案,並檢查它們是否與 macOS tar.gz 檔案完全匹配,同樣是在剝離程式碼簽名之後。從長遠來看,PKG 的建立非常簡單,有潛力將其新增到 cmd/distpack 中,但驗證器仍然必須解析 PKG 檔案才能執行忽略簽名的可執行程式碼比較。
-
Windows 的 MSI 安裝程式不會重新構建。相反,驗證器會呼叫 Linux 程式
msiextract
來提取其中的檔案,並檢查它們是否與重新構建的 Windows zip 檔案完全匹配。從長遠來看,也許 MSI 的建立可以新增到 cmd/distpack 中,然後驗證器就可以使用完全一致(bit-for-bit)的 MSI 比較。
我們每天晚上執行 gorebuild
,並將結果釋出在 go.dev/rebuild,當然任何人都可以執行它。
驗證 Ubuntu 的 Go 工具鏈
Go 工具鏈的易於重現構建意味著 go.dev 上釋出的工具鏈中的二進位制檔案應該與包含在其他打包系統中的二進位制檔案匹配,即使這些打包者是從原始碼構建的。即使打包者使用不同的配置或其他更改進行了編譯,易於重現的構建仍然應該使其輕鬆重現他們的二進位制檔案。為了證明這一點,讓我們重現 Ubuntu 針對 Linux/x86-64 的 golang-1.21
包版本 1.21.0-1
。
首先,我們需要下載並提取 Ubuntu 包,它們是包含 zstd 壓縮的 tar 歸檔檔案的 ar(1) 歸檔檔案
$ 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 在構建 Go 時使用了 GO386=softfloat
,這在為 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,即使無法改善現有壓縮,該工具也會重寫時間戳。- 二進位制檔案
dist
和distpack
,它們是在引導期間構建的,但未包含在標準歸檔檔案中,已包含在 Ubuntu 包中。 - Plan 9 構建指令碼 (
*.rc
) 已被刪除,但 Windows 構建指令碼 (*.bat
) 仍然保留。 mksyscall.pl
和其他七個未顯示的 Perl 指令碼的頭部已更改。
特別注意,我們已經按位完全重構了工具鏈二進位制檔案:它們根本沒有出現在 diff 中。也就是說,我們證明了 Ubuntu 的 Go 二進位制檔案與上游 Go 原始碼完全對應。
更好的是,我們完全沒有使用任何 Ubuntu 軟體就證明了這一點:這些命令是在 Mac 上執行的,unzstd
和 elfstrip
是簡短的 Go 程式。老練的攻擊者可能會透過修改包建立工具,將惡意程式碼插入到 Ubuntu 包中。如果他們這樣做了,使用這些惡意工具從乾淨的原始碼重現 Go Ubuntu 包仍然會產生完全相同的(bit-for-bit identical)惡意包副本。這種攻擊對於這種型別的重構建是不可見的,很像 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 專案旨在提高所有開源軟體的可重現性,是獲取關於如何使自己的軟體構建可重現的更多資訊的良好起點。