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 的主机上构建特定工具链(例如,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 使用 Go 1.4,用 C 编写;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 版本更改为另一个 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 系统会回退到net
包的纯 Go 版本,而在极少数情况下,如果这不够好,它们可以安装 C 工具链。
在删除预编译的包后,Go 工具链中唯一仍然依赖于主机 C 工具链的部分是使用 net
包构建的二进制文件,特别是 go
命令。借助 macOS 增强功能,现在可以禁用 cgo
构建这些命令,从而完全删除主机 C 工具链作为输入,但我们将这一最后一步留给 Go 1.21。
主机动态链接器。 当程序在使用动态链接 C 库的系统上使用 cgo
时,生成的二进制文件将包含指向系统动态链接器的路径,例如 /lib64/ld-linux-x86-64.so.2
。如果路径错误,二进制文件将无法运行。通常,每个操作系统/体系结构组合都有一个针对此路径的单一正确答案。不幸的是,基于 musl 的 Linuxes(如 Alpine Linux)使用的动态链接器与基于 glibc 的 Linuxes(如 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 系统上无修改运行的 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 引导过程检查了构建系统,以确保生成的工具链在该系统上工作。出于历史原因,规则是“假设 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 发行版。除了体系结构之外,这两个系统几乎没有任何共同点。这两个系统必须生成位对位相同的存档,否则我们将不会继续发布。
为了允许其他人验证我们是否诚实,我们编写并发布了一个验证器,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,该工具即使无法改进现有压缩也会重新写入时间戳。- 在引导期间构建但未包含在标准存档中的二进制文件
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 这样的框架专注于来源和软件链的监管,这可以用来指导有关信任的决策。可重现构建通过提供一种验证信任是否被合理放置的方法来补充这种方法。
完美可重现性(当源文件是构建的唯一相关输入时)仅适用于构建自身的程序,例如编译器工具链。它是一个崇高但值得的目标,正是因为自托管编译器工具链在其他情况下非常难以验证。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 之外,可重现构建 项目旨在提高所有开源软件的可重现性,并且是有关使您自己的软件构建可重现的更多信息的良好起点。
下一篇文章:Go 1.21 中的概要指导优化
上一篇文章:使用 slog 进行结构化日志记录
博客索引