Go 博客

Go 命令 PATH 安全性

Russ Cox
2021 年 1 月 19 日

今天发布的 Go 安全更新 修复了一个涉及在不可信目录中进行 PATH 查找的问题,该问题可能导致在执行 go get 命令期间发生远程代码执行。我们预计大家会对此具体含义以及自己的程序是否可能存在类似问题感到疑问。本文将详细介绍该漏洞、我们已应用的修复措施、如何判断你的程序是否容易受到类似问题的影响,以及如果存在此类问题可以采取的措施。

Go 命令与远程执行

go 命令的设计目标之一是,大多数命令——包括 go buildgo docgo getgo installgo list——不会运行从互联网下载的任意代码。有一些明显的例外:显然 go rungo testgo generate 确实会运行任意代码——这是它们的作用。但其他命令则不能,原因包括可重现构建和安全性。因此,当 go get 可以被欺骗执行任意代码时,我们认为这是一个安全漏洞。

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

然而,今天的漏洞完全是我们的过错,而不是 gccgit 的错误或晦涩特性。该漏洞涉及 Go 和其他程序如何查找其他可执行文件,因此我们需要花一些时间了解这一点,然后才能深入细节。

命令、PATH 与 Go

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

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

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

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

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

例如,当你在命令行中输入命令

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 源代码调用宿主 C 编译器(gccclang)进行构建。所有这些都运行良好。但是 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 的恶意包,然后任何在 Windows 上运行 go get 下载并构建攻击者包的用户都将优先运行攻击者提供的 gcc.exe,而不是系统路径中的任何 gcc

Unix 系统避免这个问题,首先是因为“点”通常不在 PATH 中,其次是因为模块解压不会对写入的文件设置执行位。但是,在 PATH 中将“点”放在系统目录之前并使用 GOPATH 模式的 Unix 用户,其易受攻击性将与 Windows 用户一样。(如果这是你的情况,今天是个好日子,你应该从你的 PATH 中删除“点”并开始使用 Go modules。)

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

修复措施

go get 命令下载并运行恶意的 gcc.exe 显然是不可接受的。但允许这种情况发生的实际错误是什么?以及修复措施是什么?

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

另一种可能的答案是,错误在于在 PATH 查找期间在“点”目录中进行搜索,无论这在 Windows 上是自动发生,还是因为 Unix 系统上的明确 PATH 条目。用户可能希望在“点”目录中查找他们在控制台或 shell 窗口中输入的命令,但不太可能希望在那里查找一个已输入命令的子进程的子进程。如果这是错误,那么修复措施就是修改 cgo 命令,使其在 PATH 查找期间不在“点”目录中进行搜索。

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

出于谨慎考虑,我们还在 goimportsgopls 等命令以及 golang.org/x/tools/go/analysisgolang.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

即使在其作者尚未更新之前,你也可以通过在 go get 期间添加依赖项的明确升级来更新依赖于 golang.org/x/tools/go/packages 的程序

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

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

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

你的程序是否受影响?

如果你在自己的程序中使用了 exec.LookPathexec.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.Commandexec.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 添加泛型的提案
博客索引