Go 博客

防遍历文件API

Damien Neil
2025年3月12日

当攻击者能够诱骗程序打开并非其预期文件时,就会出现路径遍历漏洞。本文解释了这类漏洞、一些现有防御措施,并描述了Go 1.24中新增的os.Root API如何提供简单而强大的防御,以防止意外的路径遍历。

路径遍历攻击

“路径遍历”涵盖了遵循共同模式的一系列相关攻击:程序尝试在某个已知位置打开文件,但攻击者导致它在不同位置打开文件。

如果攻击者控制文件名的一部分,他们可能会使用相对目录组件(“..”)来逃逸预期位置。

f, err := os.Open(filepath.Join(trustedLocation, "../../../../etc/passwd"))

在Windows系统上,某些名称具有特殊含义。

// f will print to the console.
f, err := os.Create(filepath.Join(trustedLocation, "CONOUT$"))

如果攻击者控制本地文件系统的一部分,他们可能会使用符号链接导致程序访问错误的文件。

// Attacker links /home/user/.config to /home/otheruser/.config:
err := os.WriteFile("/home/user/.config/foo", config, 0o666)

如果程序通过首先验证预期文件不包含任何符号链接来防御符号链接遍历,它仍然可能容易受到检查时与使用时(TOCTOU)竞争条件的影响,即攻击者在程序检查后创建符号链接。

// Validate the path before use.
cleaned, err := filepath.EvalSymlinks(unsafePath)
if err != nil {
  return err
}
if !filepath.IsLocal(cleaned) {
  return errors.New("unsafe path")
}

// Attacker replaces part of the path with a symlink.
// The Open call follows the symlink:
f, err := os.Open(cleaned)

另一种TOCTOU竞争涉及在遍历过程中移动构成路径一部分的目录。例如,攻击者提供了一个路径,如“a/b/c/../../etc/passwd”,并在打开操作进行中时将“a/b/c”重命名为“a/b”。

路径清理

在我们普遍解决路径遍历攻击之前,让我们从路径清理开始。当程序的威胁模型不包括拥有本地文件系统访问权限的攻击者时,在使用不受信任的输入路径之前对其进行验证就足够了。

不幸的是,清理路径可能出奇地棘手,特别是对于必须同时处理Unix和Windows路径的可移植程序。例如,在Windows上,filepath.IsAbs(`\foo`)报告false,因为路径“\foo”是相对于当前驱动器的。

在Go 1.20中,我们添加了path/filepath.IsLocal函数,它报告路径是否为“本地”路径。“本地”路径是指

  • 不逃逸其被评估的目录(不允许“../etc/passwd”);
  • 不是绝对路径(不允许“/etc/passwd”);
  • 不为空(不允许“”);
  • 在Windows上,不是保留名称(不允许“COM1”)。

在Go 1.23中,我们添加了path/filepath.Localize函数,它将以/分隔的路径转换为本地操作系统路径。

接受并操作可能由攻击者控制的路径的程序,几乎总是应该使用filepath.IsLocalfilepath.Localize来验证或清理这些路径。

超越清理

当攻击者可能访问本地文件系统的一部分时,路径清理是不够的。

多用户系统如今并不常见,但攻击者对文件系统的访问仍可通过多种方式发生。一个解压tar或zip文件的解压工具可能会被诱导解压一个符号链接,然后解压一个遍历该链接的文件名。容器运行时可能会授予不受信任的代码对本地文件系统一部分的访问权限。

程序可以通过使用path/filepath.EvalSymlinks函数在验证之前解析不受信任名称中的链接来防御意外的符号链接遍历,但如上所述,这种两步过程容易受到TOCTOU竞争条件的影响。

在Go 1.24之前,更安全的选项是使用github.com/google/safeopen等包,它提供了防路径遍历函数,用于在特定目录中打开可能不受信任的文件名。

引入os.Root

在Go 1.24中,我们将在os包中引入新的API,以安全地、以防遍历的方式在某个位置打开文件。

新的os.Root类型表示本地文件系统中的某个目录。使用os.OpenRoot函数打开一个根:

root, err := os.OpenRoot("/some/root/directory")
if err != nil {
  return err
}
defer root.Close()

Root提供了在根目录内操作文件的方法。这些方法都接受相对于根目录的文件名,并禁止任何使用相对路径组件(“..”)或符号链接逃逸根目录的操作。

f, err := root.Open("path/to/file")

Root允许不逃逸根目录的相对路径组件和符号链接。例如,root.Open("a/../b")是允许的。文件名使用本地平台的语义解析:在Unix系统上,这将遵循“a”中的任何符号链接(只要该链接不逃逸根目录);而在Windows系统上,这将打开“b”(即使“a”不存在)。

Root目前提供以下操作集:

func (*Root) Create(string) (*File, error)
func (*Root) Lstat(string) (fs.FileInfo, error)
func (*Root) Mkdir(string, fs.FileMode) error
func (*Root) Open(string) (*File, error)
func (*Root) OpenFile(string, int, fs.FileMode) (*File, error)
func (*Root) OpenRoot(string) (*Root, error)
func (*Root) Remove(string) error
func (*Root) Stat(string) (fs.FileInfo, error)

除了Root类型,新的os.OpenInRoot函数提供了一种简单的方法,可以在特定目录中打开可能不受信任的文件名。

f, err := os.OpenInRoot("/some/root/directory", untrustedFilename)

Root类型为处理不受信任的文件名提供了一个简单、安全、可移植的API。

注意事项和考量

Unix

在Unix系统上,Root使用openat系统调用族实现。一个Root包含一个文件描述符,引用其根目录,并将在重命名或删除后跟踪该目录。

Root防御符号链接遍历,但不限制挂载点的遍历。例如,Root不阻止Linux绑定挂载的遍历。我们的威胁模型是Root防御普通用户可能创建的文件系统构造(如符号链接),但不处理需要root权限才能创建的构造(如绑定挂载)。

Windows

在Windows上,Root打开一个引用其根目录的句柄。在Root关闭之前,该打开的句柄会阻止该目录被重命名或删除。

Root阻止访问保留的Windows设备名称,例如NULCOM1

WASI

在WASI上,os包使用WASI预览版1文件系统API,这些API旨在提供防遍历的文件系统访问。然而,并非所有WASI实现都完全支持文件系统沙盒,并且Root对遍历的防御仅限于WASI实现提供的防御。

GOOS=js

当GOOS=js时,os包使用Node.js文件系统API。此API不包括openat系列函数,因此os.Root在该平台上容易受到符号链接验证中的TOCTOU(检查时与使用时)竞争条件的影响。

当GOOS=js时,Root引用目录名而不是文件描述符,并且在重命名后不跟踪目录。

Plan 9

Plan 9没有符号链接。在Plan 9上,Root引用目录名并对文件名执行词法清理。

性能

包含许多目录组件的文件名上的Root操作可能比等效的非Root操作昂贵得多。解析“..”组件也可能很昂贵。希望限制文件系统操作成本的程序可以使用filepath.Clean从输入文件名中删除“..”组件,并且可能希望限制目录组件的数量。

谁应该使用os.Root?

如果您符合以下条件,则应使用os.Rootos.OpenInRoot

  • 您正在目录中打开文件;并且
  • 该操作不应访问该目录之外的文件。

例如,将文件写入输出目录的存档提取器应使用os.Root,因为文件名可能不受信任,并且将文件写入输出目录之外是不正确的。

然而,将输出写入用户指定位置的命令行程序不应使用os.Root,因为文件名不是不受信任的,并且可能指代文件系统上的任何位置。

一个好的经验法则是,调用filepath.Join将固定目录和外部提供的文件名组合起来的代码,很可能应该改用os.Root

// This might open a file not located in baseDirectory.
f, err := os.Open(filepath.Join(baseDirectory, filename))

// This will only open files under baseDirectory.
f, err := os.OpenInRoot(baseDirectory, filename)

未来工作

os.Root API是Go 1.24中新增的。我们预计在未来的版本中对其进行添加和改进。

当前的实现优先考虑正确性和安全性而非性能。未来的版本将利用平台特定的API(如Linux的openat2)来尽可能提高性能。

Root目前尚不支持许多文件系统操作,例如创建符号链接和重命名文件。在可能的情况下,我们将添加对这些操作的支持。正在进行的额外函数列表请参见go.dev/issue/67002

下一篇文章:再见核心类型 - 你好,我们所熟知和喜爱的Go!
上一篇文章:从unique到cleanup和weak:提高效率的新底层工具
博客索引