Go 博客

可完全重现、可验证的 Go 工具链

Russ Cox
2023 年 8 月 28 日

开源软件的主要优势之一是任何人都可以阅读源代码并检查其功能。然而,大多数软件(即使是开源软件)都是以编译后的二进制形式下载的,这使得检查变得困难得多。如果攻击者想要对开源项目发动供应链攻击,最不显眼的方法就是替换提供的二进制文件,同时保持源代码不变。

解决这种攻击的最佳方法是使开源软件的构建过程可重现,这意味着从相同源代码开始的构建,每次运行时都会产生相同的输出。这样,任何人都可以通过从真实的源代码进行构建,并检查重新构建的二进制文件是否与发布的二进制文件完全一致(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 工具 pkgbuildproductbuild 创建可下载的 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,即使无法改善现有压缩,该工具也会重写时间戳。
  • 二进制文件 distdistpack,它们是在引导期间构建的,但未包含在标准归档文件中,已包含在 Ubuntu 包中。
  • Plan 9 构建脚本 (*.rc) 已被删除,但 Windows 构建脚本 (*.bat) 仍然保留。
  • mksyscall.pl 和其他七个未显示的 Perl 脚本的头部已更改。

特别注意,我们已经按位完全重构了工具链二进制文件:它们根本没有出现在 diff 中。也就是说,我们证明了 Ubuntu 的 Go 二进制文件与上游 Go 源代码完全对应。

更好的是,我们完全没有使用任何 Ubuntu 软件就证明了这一点:这些命令是在 Mac 上运行的,unzstdelfstrip 是简短的 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 项目旨在提高所有开源软件的可重现性,是获取关于如何使自己的软件构建可重现的更多信息的良好起点。

下一篇文章:Go 1.21 中的 Profile-guided optimization
上一篇文章:使用 slog 进行结构化日志记录
博客索引