Go 部落格
完美可重現、已驗證的 Go 工具鏈
開源軟體的主要好處之一是,任何人都能閱讀原始碼並檢查其功能。然而,大多數的軟體,即使是開源軟體,都是下載經過編譯的二進位檔,這種檔案難以檢查得多。如果攻擊者想對開源專案執行 供應鏈攻擊,最不明顯的方法就是取代提供的二進位檔,同時不修改原始碼。
解決這種攻擊的最佳方法是讓開源軟體建置變得可重現,也就是每次從相同的來源開始建置,都會產生相同的輸出。這樣一來,任何人都能透過從真實的來源建置來驗證發佈的二進位檔沒有隱藏的變更,並檢查重新建置的二進位檔是否在按位元正確性上,與發佈的二進位檔完全相同。這種方式可以證明二進位檔沒有後門程式或未出現在原始碼中的其他變更,而無需反組譯或查看其內部。由於任何人都可以驗證二進位檔,因此獨立的組織可以輕易偵測和通報供應鏈攻擊。
隨著供應鏈安全性變得越來越重要,可重複建置也如此,因為它們提供一種簡單的方法來驗證開源專案中發佈的二進制檔案。
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 使用由 C 語言撰寫的 Go 1.4;Go 1.21 使用 Go 1.17) 來建置現有 Go 工具鏈的來源。這會產生「toolchain1」,我們會再次使用它來建置所有內容,產生「toolchain2」,我們會再次使用它來建置所有內容,產生「toolchain3」。
Toolchain1 和 toolchain2 是由相同來源建置的,但使用不同的 Go 實作(編譯器和函式庫),因此其二進位檔案必定不同。不過,如果兩個 Go 實作都是沒有偵錯且正確的實作,toolchain1 和 toolchain2 應該表現得完全相同。特別是,在呈現 Go 1.X 來源時,toolchain1 的輸出 (toolchain2) 和 toolchain2 的輸出 (toolchain3) 應該相同,這表示 toolchain2 和 toolchain3 應該是相同的。
至少,這是構想。要實際做到這一點,需要移除幾個意外輸入
隨機性。使用鎖定序列化地圖反覆運算和於多個 goroutine 執行工作兩者都會在結果可能生成的順序中產生隨機性。此隨機性會造成每次執行工具鏈時產生多個不同可能的輸出的其中一者。若要使建置可複製,我們必須找出每一個並在使用其產生輸出前,對相關項目清單排序。
引導函式庫。由編譯器使用的任何函式庫,皆可以從多個不同的正確輸出中進行選擇,可能在其不同的 Go 版本之間變更其輸出。如果該函式庫輸出變更造成編譯器輸出變更,則 toolchain1 和 toolchain2 將在語意上不同,而 toolchain2 和 toolchain3 則將逐位不同。
其典型範例為 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 系統會改用 package net 的純粹 Go 版本,而在少數這種做法不夠好的情況下,他們可以安裝 C 工具鏈。
拋棄預編譯 package 後,Go 工具鏈唯一依賴主機 C 工具鏈的部分是使用 package net 衍生的二進位檔,特別是 go
指令。藉助於 macOS 的改進,現在可以用停用的 cgo
衍生這些指令,完全移除主機 C 工具鏈作為輸入,但是我們在 Go 1.21 中保留了最後這個步驟。
主機動態連結器。當程式在使用動態連結 C 函式庫的系統上使用 cgo
時,衍生的二進位檔會包含系統動態連結器的路徑,例如像 /lib64/ld-linux-x86-64.so.2
之類的內容。如果路徑錯誤,二進位檔就無法執行。一般來說,每個作業系統/架構組合對這個路徑只有單一的正確答案。不幸的是,Alpine Linux 等基於 musl 的 Linux 使用與 Ubuntu 等基於 Glibc 的 Linux 不同的動態連結器。為了讓 Go 能夠在 Alpine Linux 上執行,Go 的引導程序看起來像這樣

引導程式 cmd/dist 會檢查本機系統的動態連結器,然後將其值寫入新的原始碼檔案,與連結器其他原始碼一起編譯,實際上將預設值硬式編碼到連結器本身。然後,當連結器從一組編譯好的 package 建立程式時,它會使用那個預設值。結果是針對 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 版本,其中標準 Go 工具鏈可以在 Alpine Linux 系統上未經修改地執行。
移除這些相關輸入,使得可以從不同的系統交叉編譯 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 系統上執行,還能使用軟體函式庫進行浮點運算,或使用硬體浮點指令發出代碼(HWFP)。預設為一種模式或另一種模式的工具鏈會必然有所不同。就像我們之前在動態連結器中所看到的,Go 引導程式檢查建置系統,以確保最終的工具鏈在該系統上執行。由於歷史原因,規則便是「除非建置在具有浮點硬體的 ARM 系統上執行,否則假設為軟體浮點運算」,而交叉編譯的工具鏈則假設為軟體浮點運算。現今絕大多數的 ARM 系統都具有浮點硬體,因此這在原生編譯工具鏈和交叉編譯工具鏈之間造成了不必要的差異,而更複雜的是,Windows ARM 建置始終假設為硬體浮點運算,導致這個決策取決於作業系統。我們將規則更改為「除非建置在沒有浮點硬體的 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 套件。除了架構之外,這兩個系統幾乎沒有任何共同點。這兩個系統必須產生逐位元相同的封存檔,否則我們不會繼續進行發行。
為讓其他人驗證我們是否誠實,我們撰寫並發布了一個驗證器: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 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 使用 `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。
- 二進位檔案 `dist` 和 `distpack` 是在開機期間建立,但未包含在標準檔案館中,這些二進位檔案已包含在 Ubuntu 套件中。
- Plan 9 建置指令碼(`*.rc`)已刪除,但 Windows 建置指令碼(`*.bat`)仍存在。
- 已變更 `mksyscall.pl` 和七個其他未顯示的 Perl 指令碼的標頭。
特別注意的是,我們已經按位重建工具鏈二進位檔案:它們根本不會顯示在 diff 中。換句話說,我們證明了 Ubuntu Go 二進位檔案與上游 Go 原始碼完全對應。
更棒的是,我們在不使用任何 Ubuntu 軟體的情況下證明了這一點:這些命令是在 Mac 上執行的,而 unzstd
和 elfstrip
是簡短的 Go 程式碼。精明的攻擊者可能會透過變更套件建立工具,將惡意程式碼插入 Ubuntu 套件中。如果他們這樣做了,使用這些惡意工具從原始來源重新建立 Go Ubuntu 套件,仍然會產生完全相同的惡意套件副本。這種攻擊對於這種重建來說是不可見的,很像 Ken Thompson 編譯器攻擊。使用完全不使用 Ubuntu 軟體來驗證 Ubuntu 套件是更強大的檢查措施。Go 完全可重現的建置,不依賴於非縮進的詳細資訊,如主機作業系統、主機架構和主機 C 工具鏈,這使得這種更強大的檢查成為可能。
(作為歷史記錄的旁注,Ken Thompson 曾經告訴我,事實上他的攻擊被偵測到了,因為編譯器建置停止可重現性。它有一個錯誤:加入後門的編譯器中的字串常數處理不完全,並且每次編譯器編譯它自己時都會增加一個 NUL 位元組。最後有人注意到無法重現的建置,並試圖透過編譯成組合語言來找到原因。編譯器的後門根本不會自行重現在組合語言輸出中,所以組譯該輸出就會移除後門。)
結論
可重現建置是強化開源供應鏈的重要工具。SLSA 等框架專注於原始碼和軟體保管鏈,可用於告知有關信賴的決策。可重現建置透過提供一種方法來驗證信任是用在適當的地方,作為該方法的補充。
完美的可重現性(當原始檔案是建置唯一相關的輸入時)只有在建置自己的程式碼中才可能,例如編譯器工具鏈。這是一個崇高但有價值的目標,原因就在於自 hosting 編譯器工具鏈在其他方面很難驗證。Go 的完美可重現性意味著,假設封裝器不修改原始碼,任何形式的 Linux/x86-64(取代你最喜愛的系統)重新封裝 Go 1.21.0 都應該會散布完全相同的二進位檔,即使它們都是從原始碼建置。我們已經看到這對 Ubuntu Linux 來說並非完全正確,但完美的可重現性仍然讓我們能夠使用非常不同的、非 Ubuntu 的系統重新建置 Ubuntu 封裝。
理想情況下,以二進位形式發布的所有開放原始碼軟體應該都有容易複制的建置。實際上,正如我們在本篇文章中所見,意外輸入很容易外洩到建置中。對於不需要cgo
的 Go 程式,只要使用 CGO_ENABLED=0 go build -trimpath
編譯,就能得到可復製的建置。停用 cgo
會移除主機 C 工具鏈作為相關輸入,而 -trimpath
會移除目前目錄。如果您的程式確實需要 cgo
,您需要在執行 go build
之前安排特定版本的主機 C 工具鏈,例如在特定虛擬機器或容器映像中執行建置。
超越 Go 的可複製建置
計畫旨在改進所有開放原始碼的可複製性,是讓您自己的軟體建置可複製的進一步資訊的良好起點。
下一篇文章:Go 1.21 的概況引導最佳化
上一篇文章:使用 slog 的結構化記錄
部落格索引