Go 的命令行路径安全问题
Russ Cox
2021年1月19日
今天的Go 安全版本修复了一个涉及在不受信任的目录中进行 PATH 查找的问题,该问题可能导致在go get
命令中的远程执行。我们预计人们会有疑问,这到底意味着什么,以及他们在自己的程序中是否会出现问题。这篇文章详细介绍了这个 bug,我们所应用的修复方法,如何判断你自己的程序是否容易受到类似问题的影响,以及如果有的话你可以做什么。
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
的错误或晦涩的功能。这个 bug 涉及到 Go 和其他程序如何寻找其他可执行文件的问题,所以我们需要花一点时间来看看这个问题,然后才能进入细节。
命令和 PATHs 与 Go
所有的操作系统都有一个可执行路径的概念( Unix 中为$PATH
,Windows中为%PATH%
;为简单起见,我们只使用 PATH 这个术语),它是一个目录列表。当你在 shell 提示符下键入一个命令时,shell 会依次在列出的每个目录中寻找一个与你键入的名称相同的可执行文件。它运行它找到的第一个可执行文件,或者打印出一个类似 「未找到命令」的信息。
在 Unix 中,这个想法首次出现在第七版 Unix 的 Bourne shell(1979)中。该手册解释说。
shell参数
$PATH
定义了包含命令的目录的搜索路径。每个备选的目录名都用冒号(:
)分开。默认路径是:/bin:/usr/bin
。如果命令名包含一个/
,那么搜索路径就不会被使用。否则,路径中的每个目录都会被搜索到可执行文件。
注意默认情况:当前目录(这里用一个空字符串表示,但我们称它为 「dot」)被列在/bin
和/usr/bin
之前。MS-DOS 和后来的 Windows 选择了硬编码的行为 :在这些系统上,dot 总是被自动首先搜索,然后再考虑%PATH%
中列出的任何目录。
正如 Grampp 和 Morris 在他们的经典论文「UNIX 操作系统安全」(1984)中指出的,在 PATH 中把 dot 放在系统目录之前意味着,如果你cd
进入一个目录并运行ls
,你可能会从该目录而不是系统工具中得到一个恶意的拷贝。如果你能欺骗系统管理员在你的主目录中运行ls
,同时以root
身份登录,那么你就可以运行任何你想要的代码。由于这个问题和其他类似的问题,基本上所有现代 Unix 发行版都将新用户的默认 PATH 设置为不包括 dot。但是 Windows 系统仍然会先搜索 dot,不管 PATH 怎么说。
例如,当你输入命令
go version
时,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()
的行为与在操作系统外壳中输入go version
的行为相同。在 Windows上,当存在.\go.exe
时,它会运行.\go.exe
。
(值得注意的是,Windows PowerShell 改变了这一行为,放弃了隐含的 dot 搜索,但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()
因此,即使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编译器,在它创建的一些临时文件上,这意味着它自己执行了这些代码。
//在badpkg目录下的cgo中运行
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 系统避免了这个问题,首先是因为 dot 通常不在 PATH 中,其次是因为模块解包不会在它写入的文件上设置执行位。但是,如果 Unix 用户在他们的 PATH 中把 dot 放在系统目录的前面,并且使用 GOPATH 模式,就会像 Windows 用户一样容易受到影响。(如果这是对你的描述,今天是一个从你的路径中删除 dot 并开始使用 Go 模块的好日子。)
修复措施
go get
命令下载并运行一个恶意的gcc.exe
显然是不可接受的。但是,允许这种情况发生的实际错误是什么?然后该如何解决呢?
一个可能的答案是,错误在于cgo
在不被信任的源代码目录中搜索主机C编译器,而不是在调用go
命令的目录中搜索。如果这是错误的,那么解决方法是改变go
命令,将主机C编译器的完整路径传递给cgo
,这样cgo
就不需要在不被信任的目录中进行 PATH 查询。
另一个可能的答案是,错误的做法是在 PATH 查找过程中查找 dot,不管是在 Windows 上自动发生还是在 Unix系统上明确的 PATH 条目。用户可能想在 dot 中查找他们在控制台或 shell 窗口中输入的命令,但他们不可能也想在那里查找一个输入的命令的子进程。如果这是一个错误,那么解决的办法就是改变cgo
命令,在PATH 查找过程中不要在 dot 中查找。
我们认为这两个都是错误,所以我们应用了这两个修正。go
命令现在将完整的主机C编译器路径传递给cgo
。除此之外,cgo
、go
和 Go 发行版中的其他命令现在都使用os/exec
包的变体,如果之前使用dot中的可执行文件,则报告错误。go/build
和go/import
包在调用go
命令和其他工具时使用相同的策略。这应该可以关闭任何可能潜伏的类似安全问题的大门。
出于谨慎考虑,我们也对goimports
和gopls
等命令,以及golang.org/x/tools/go/analysis
和golang.org/x/tools/go/packages
库进行了类似的修复,这些程序作为一个子进程调用go
命令。如果你在不信任的目录中运行这些程序--例如,如果你git checkout
不信任的仓库并cd
进入它们,然后运行像这样的程序,而且你使用 Windows 或使用 Unix 的 PATH 中的 dot--那么你也应该更新这些命令的副本。如果你的电脑上唯一不受信任的目录是由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 用户或 Unix 用户,PATH 中含有 dot 时,你才需要更新这些其他程序,并且你在你不信任的可能含有恶意程序的源代码目录中运行这些程序。
你自己的程序受到影响吗?
如果你在自己的程序中使用exec.LookPath
或exec.Command
,只有当你(或你的用户)在一个内容不受信任的目录中运行你的程序时,你才需要担心。如果是这样,那么就可以使用 dot 中的可执行文件来启动子进程,而不是从系统目录中启动。(同样,使用 dot 的可执行文件在 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
仍然总是搜索当前目录,但 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。
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: