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 和其他程序如何查找其他可执行文件,因此我们需要花一些时间来了解这一点,然后才能深入了解细节。

命令、路径和 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 年)中指出的那样,将点放在 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 命令本身就会在生成的文件和主机 C 编译器(gccclang)上调用 Go 编译器来构建与软件包一起包含的任何 C 源代码。所有这些都很好地运行。但 go 命令在哪里找到主机 C 编译器?当然是在 PATH 中。幸运的是,虽然它在软件包源代码目录中运行 C 编译器,但它是在调用 go 命令的原始目录中进行 PATH 查找的

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

因此,即使 badpkg\gcc.exe 在 Windows 系统上存在,这段代码片段也找不到它。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 中,其次通过模块解包不会在其写入的文件上设置执行位来避免这个问题。但是,在他们的 PATH 中将点放在系统目录之前的 Unix 用户以及正在使用 GOPATH 模式的用户会像 Windows 用户一样容易受到攻击。(如果您的情况是这样的,今天是将点从您的路径中删除并开始使用 Go 模块的好日子。)

(感谢 RyotaK 向我们 报告了这个问题。)

修复程序

go get 命令下载并运行恶意 gcc.exe 显然是不可接受的。但究竟是什么错误导致了这种情况?那么修复方法是什么呢?

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

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

我们发现这两个都是错误,因此我们同时应用了这两个修复程序。go 命令现在会将完整的主机 C 编译器路径传递给 cgo。除此之外,cgogo 以及 Go 发行版中的所有其他命令现在都使用 os/exec 包的一个变体,该变体如果先前使用的是来自 dot 的可执行文件,则会报告错误。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/[email protected]

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

GO111MODULE=on \
go get golang.org/x/tools/cmd/[email protected]

即使在作者进行更新之前,也可以通过在 go get 期间添加显式依赖项升级来更新依赖 golang.org/x/tools/go/packages 的程序

GO111MODULE=on \
go get example.com/cmd/thecmd golang.org/x/[email protected]

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

同样,只有在您是 Windows 用户或在 PATH 中使用点的 Unix 用户并且还在运行这些程序时,才需要更新这些其他程序,这些程序位于您不信任可能包含恶意程序的源代码目录中。

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

如果您在自己的程序中使用 exec.LookPathexec.Command,则只有在您(或您的用户)在包含不受信任内容的目录中运行程序时才需要担心。如果是这样,则可以使用来自 dot 的可执行文件而不是来自系统目录的可执行文件来启动子进程。(同样,在 Windows 上始终使用来自 dot 的可执行文件,而在 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 仍然总是搜索当前目录,但 PowerShell(cmd.exe 的继任者)却没有,这显然表明原始行为是错误的。反对更改的论点是,它可能会破坏现有的 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.

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