Go 博客

Go 语言中的命令行 PATH 安全

Russ Cox
2021 年 1 月 19 日

今天的Go 安全版本修复了一个问题,该问题涉及在不受信任的目录中进行 PATH 查找,这可能导致在 `go get` 命令执行期间进行远程执行。我们预计人们会就这到底意味着什么以及他们的程序中是否存在问题提出疑问。本文详细介绍了这个错误、我们应用的修复、如何判断您自己的程序是否容易受到类似问题的影响以及如果受到影响您可以怎么做。

Go 命令与远程执行

`go` 命令的设计目标之一是大多数命令——包括 `go build`、`go doc`、`go get`、`go install` 和 `go list`——不运行从互联网下载的任意代码。有几个明显的例外:显然 `go run`、`go test` 和 `go generate` 确实会运行任意代码——这是它们的工作。但其他命令不能这样做,原因有很多,包括可重现的构建和安全性。因此,当 `go get` 可以被诱骗执行任意代码时,我们将其视为一个安全漏洞。

如果 `go get` 不能运行任意代码,那么不幸的是,这意味着它调用的所有程序,例如编译器和版本控制系统,也在安全边界之内。例如,我们过去曾遇到过这样的问题:巧妙地利用晦涩的编译器功能或版本控制系统中的远程执行漏洞,变成了 Go 中的远程执行漏洞。(值得一提的是,Go 1.16 旨在通过引入 GOVCS 设置来改善这种情况,该设置允许配置允许哪些版本控制系统以及何时允许。)

然而,今天的错误完全是我们的错,而不是 `gcc` 或 `git` 的错误或晦涩功能。该错误涉及 Go 和其他程序如何找到其他可执行文件,因此我们需要花一点时间来研究它,然后才能深入了解细节。

命令、PATH 和 Go

所有操作系统都有一个可执行路径(Unix 上是 `$PATH`,Windows 上是 `%PATH%`;为简单起见,我们只使用术语 PATH)的概念,它是一个目录列表。当您在 shell 提示符下键入命令时,shell 会依次在每个列出的目录中查找名称与您键入的命令相同的可执行文件。它会运行找到的第一个文件,或者打印一条消息,例如“command not found”。

在 Unix 上,这个想法首次出现在第七版 Unix 的 Bourne shell (1979) 中。手册解释道:

shell 参数 `$PATH` 定义了包含命令的目录的搜索路径。每个备用目录名都由冒号(`:`)分隔。默认路径是 `:/bin:/usr/bin`。如果命令名包含 `/`,则不使用搜索路径。否则,会在路径中的每个目录中搜索可执行文件。

请注意默认值:当前目录(此处用空字符串表示,但我们称之为“点”)列在 `/bin` 和 `/usr/bin` 之前。MS-DOS 和后来的 Windows 选择硬编码这种行为:在这些系统上,点总是首先自动搜索,然后才考虑 `%PATH%` 中列出的任何目录。

正如 Grampp 和 Morris 在他们的经典论文“UNIX 操作系统安全”(1984 年)中指出的那样,将点放在 PATH 中系统目录前面意味着,如果您 `cd` 到一个目录并运行 `ls`,您可能会从该目录获取一个恶意副本而不是系统工具。如果您能诱骗系统管理员在以 `root` 身份登录时在您的主目录中运行 `ls`,那么您就可以运行任何您想要的代码。由于这个问题和类似的问题,几乎所有现代 Unix 发行版都将新用户的默认 PATH 设置为排除点。但 Windows 系统无论 PATH 如何设置,都会继续首先搜索点。

例如,当您输入命令

go version

在典型配置的 Unix 系统上,shell 会从您的 PATH 中的系统目录运行 `go` 可执行文件。但是当您在 Windows 上输入该命令时,`cmd.exe` 会首先检查当前目录。如果存在 `.\go.exe`(或 `.\go.bat` 或许多其他选择),`cmd.exe` 将运行该可执行文件,而不是 PATH 中的可执行文件。

对于 Go,PATH 搜索由 exec.LookPath 处理,该函数由 exec.Command 自动调用。为了与宿主系统良好兼容,Go 的 exec.LookPath 在 Unix 上实现 Unix 规则,在 Windows 上实现 Windows 规则。例如,这个命令

out, err := exec.Command("go", "version").CombinedOutput()

其行为与在操作系统 shell 中键入 `go version` 相同。在 Windows 上,如果 `.\go.exe` 存在,它将运行该文件。

(值得注意的是,Windows PowerShell 改变了这种行为,放弃了对点的隐式搜索,但 `cmd.exe` 和 Windows C 库的 SearchPath 函数 仍然像以前一样运行。Go 继续与 `cmd.exe` 匹配。)

这个错误

当 `go get` 下载并构建包含 `import "C"` 的包时,它会运行一个名为 `cgo` 的程序,以准备相应 C 代码的 Go 等效项。`go` 命令在包含包源文件的目录中运行 `cgo`。一旦 `cgo` 生成了其 Go 输出文件,`go` 命令本身就会在生成的 Go 文件上调用 Go 编译器,并调用主机 C 编译器(`gcc` 或 `clang`)来构建包中包含的任何 C 源文件。所有这些都运行良好。但是 `go` 命令在哪里找到主机 C 编译器呢?它当然会在 PATH 中查找。幸运的是,虽然它在包源目录中运行 C 编译器,但它从最初调用 `go` 命令的目录中进行 PATH 查找。

cmd := exec.Command("gcc", "file.c")
cmd.Dir = "badpkg"
cmd.Run()

因此,即使 Windows 系统上存在 `badpkg\gcc.exe`,此代码片段也不会找到它。在 `exec.Command` 中发生的查找不知道 `badpkg` 目录。

`go` 命令使用类似的代码来调用 `cgo`,在这种情况下甚至没有路径查找,因为 `cgo` 总是来自 GOROOT。

cmd := exec.Command(GOROOT+"/pkg/tool/"+GOOS_GOARCH+"/cgo", "file.go")
cmd.Dir = "badpkg"
cmd.Run()

这甚至比上一个代码片段更安全:没有机会运行任何可能存在的恶意 `cgo.exe`。

但事实证明,cgo 本身也会在它创建的一些临时文件上调用宿主 C 编译器,这意味着它自己执行了这段代码。

// running in cgo in badpkg dir
cmd := exec.Command("gcc", "tmpfile.c")
cmd.Run()

现在,因为 cgo 本身在 `badpkg` 中运行,而不是在运行 `go` 命令的目录中,如果 `badpkg\gcc.exe` 文件存在,它将运行该文件,而不是找到系统 `gcc`。

因此,攻击者可以创建一个使用 cgo 并包含 `gcc.exe` 的恶意包,然后任何运行 `go get` 下载并构建攻击者包的 Windows 用户都将运行攻击者提供的 `gcc.exe`,而不是系统路径中的任何 `gcc`。

Unix 系统避免此问题,首先是因为点通常不在 PATH 中,其次是因为模块解包不会在其写入的文件上设置执行位。但是,如果 Unix 用户在 PATH 中将点放在系统目录之前并使用 GOPATH 模式,他们将与 Windows 用户一样容易受到攻击。(如果这描述了您,那么今天是将点从您的路径中删除并开始使用 Go 模块的好日子。)

(感谢 RyotaK 向我们报告此问题。)

修复

`go get` 命令下载并运行恶意的 `gcc.exe` 显然是不可接受的。但是导致这种情况的实际错误是什么?然后如何修复?

一个可能的答案是,错误在于 `cgo` 在不受信任的源目录中搜索宿主 C 编译器,而不是在调用 `go` 命令的目录中。如果这是错误,那么修复方法是更改 `go` 命令,将宿主 C 编译器的完整路径传递给 `cgo`,这样 `cgo` 就不必在不受信任的目录中进行 PATH 查找。

另一个可能的答案是,错误在于 PATH 查找期间查找当前目录,无论是在 Windows 上自动发生还是由于 Unix 系统上的显式 PATH 条目。用户可能希望在当前目录中查找他们在控制台或 shell 窗口中键入的命令,但他们不太可能也希望在那里查找键入命令的子进程的子进程。如果这是错误,那么修复方法是更改 `cgo` 命令,使其在 PATH 查找期间不查找当前目录。

我们认为两者都是错误,所以我们应用了这两个修复。`go` 命令现在将完整的宿主 C 编译器路径传递给 `cgo`。在此之上,`cgo`、`go` 和 Go 发行版中的所有其他命令现在都使用 `os/exec` 包的一个变体,如果它以前会使用来自当前目录的可执行文件,则会报告错误。`go/build` 和 `go/import` 包对其调用 `go` 命令和其他工具也使用相同的策略。这应该能杜绝任何可能潜伏的类似安全问题。

出于谨慎起见,我们还在 `goimports` 和 `gopls` 等命令以及 `golang.org/x/tools/go/analysis` 和 `golang.org/x/tools/go/packages` 库中进行了类似的修复,这些库将 `go` 命令作为子进程调用。如果您在不受信任的目录中运行这些程序——例如,如果您 `git checkout` 不受信任的存储库并 `cd` 进入它们,然后运行这些程序,并且您使用的是 Windows 或在 PATH 中包含点的 Unix 系统——那么您也应该更新这些命令的副本。如果您计算机上唯一不受信任的目录是 `go get` 管理的模块缓存中的目录,那么您只需要新的 Go 版本。

更新到新的 Go 版本后,您可以使用以下命令更新到最新的 `gopls`:

GO111MODULE=on \
go get golang.org/x/tools/gopls@v0.6.4

您可以使用以下命令更新到最新的 `goimports` 或其他工具:

GO111MODULE=on \
go get golang.org/x/tools/cmd/goimports@v0.1.0

您可以更新依赖于 `golang.org/x/tools/go/packages` 的程序,即使在它们的作者这样做之前,通过在 `go get` 期间显式升级依赖项:

GO111MODULE=on \
go get example.com/cmd/thecmd golang.org/x/tools@v0.1.0

对于使用 `go/build` 的程序,您只需使用更新后的 Go 版本重新编译它们。

再次强调,您只有在是 Windows 用户或 PATH 中包含点的 Unix 用户,并且在您不信任的可能包含恶意程序的源目录中运行这些程序时,才需要更新这些其他程序。

您自己的程序是否受到影响?

如果您在自己的程序中使用 `exec.LookPath` 或 `exec.Command`,则只有当您(或您的用户)在包含不受信任内容的目录中运行您的程序时,才需要担心。如果是这样,那么子进程可能会使用来自当前目录的可执行文件启动,而不是来自系统目录。(再次强调,在 Windows 上总是会发生使用来自当前目录的可执行文件,而在 Unix 上仅在不常见的 PATH 设置下发生。)

如果您担心,我们已将 `os/exec` 的更受限制的变体发布为 golang.org/x/sys/execabs。您只需替换即可在程序中使用它:

import "os/exec"

替换为

import exec "golang.org/x/sys/execabs"

并重新编译。

默认情况下保护 os/exec

我们一直在讨论 golang.org/issue/38736,关于是否应该更改 Windows 在 PATH 查找(在 `exec.Command` 和 `exec.LookPath` 期间)中总是优先选择当前目录的行为。支持此更改的论点是它解决了本博客文章中讨论的安全问题。一个辅助论点是,尽管 Windows `SearchPath` API 和 `cmd.exe` 仍然总是搜索当前目录,但 `cmd.exe` 的继任者 PowerShell 却不这样做,这显然是承认原始行为是一个错误。反对更改的论点是,它可能会破坏旨在在当前目录中查找程序的现有 Windows 程序。我们不知道有多少这样的程序存在,但如果 PATH 查找开始完全跳过当前目录,它们将遇到无法解释的故障。

我们在 `golang.org/x/sys/execabs` 中采用的方法可能是一个合理的中间地带。它找到旧 PATH 查找的结果,然后返回一个明确的错误,而不是使用来自当前目录的结果。当 `prog.exe` 存在时,`exec.Command("prog")` 返回的错误看起来像这样:

prog resolves to executable in current directory (.\prog.exe)

对于确实改变行为的程序,此错误应该非常清楚地说明发生了什么。打算从当前目录运行程序的程序可以使用 `exec.Command("./prog")` 代替(这种语法适用于所有系统,包括 Windows)。

我们已将此想法作为一项新提案提交,golang.org/issue/43724

下一篇文章:VS Code Go 扩展中默认启用 Gopls
上一篇文章:Go 泛型提案
博客索引