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 进行结构化日志记录
博客索引