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 preview 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 到 cleanups 和 weak:用于提高效率的新底层工具
博客索引