首页
/ Moby 仓库中的 pathrs-lite:纯 Go 实现的容器安全路径解析过渡层深入解析

Moby 仓库中的 pathrs-lite:纯 Go 实现的容器安全路径解析过渡层深入解析

2026-09-06 18:57:21作者:农烁颖Land

导读

在 Moby(Docker 引擎)的 vendor 目录中,vendored 了一份名为 pathrs-lite 的 Go 库,它属于 github.com/cyphar/filepath-securejoin 模块。它的使命很纯粹:以 100% 纯 Go 提供 [libpathrs] 最核心的"在指定根目录内安全解析、打开、创建路径"能力,作为现有 Go 项目向 libpathrs 迁移的过渡工具,并通过一个 build tag 实现与 C 版 libpathrs 的零感知切换。读完本文,你将掌握 OpenInRoot / MkdirAll 等安全路径 API 的语义、为什么 SecureJoin 这类"先拼接字符串再 open"的旧 API 存在 TOCTOU 竞态缺陷,以及 Moby 构建链中这套安全路径解析能力的落地形态。

说明:pathrs-lite 属于 Moby 的第三方依赖(在 go.mod 中以 github.com/cyphar/filepath-securejoin v0.6.1 // indirect 引入),并非 daemon 核心代码。因此本文以该子包及其源码为主要分析对象,仅在最后小节介绍它在 Moby 中的引用位置。文中所有代码路径均以仓库根目录为基准,可在当前 Moby 源码树中直接检索验证。

一、pathrs-lite 的定位:纯 Go 的 libpathrs "最小核心"

pathrs-lite 的 README 开篇即点明其设计目标:

  • 它是 [libpathrs] 核心能力的 minimal(最小)纯 Go 实现,只覆盖"核心 bits";
  • 并不打算成为 libpathrs 的完整替代品,而主要是为现有 Go 项目提供一个过渡(transition)工具;
  • 它提供了极其简单的迁移路径:即使下游在使用第三方包时间接引入了 pathrs-lite、且不想引入 CGo,也能在构建期无缝切到真正的 libpathrs。

这一"过渡工具"定位,与上游 filepath-securejoin 新 API(OpenInRootMkdirAll)一脉相承。libpathrs 本身是面向容器运行时的 C 库,提供了比 openat2 封装更丰富的安全句柄操作助手;pathrs-lite 则先在纯 Go 世界把这些最核心的语义落地,保证将来切换到 C 版本时代码层面几乎零改动。

模块结构与包名

先看 vendor 内的目录布局:

vendor/github.com/cyphar/filepath-securejoin/
├── README.md            # filepath-securejoin 主模块说明
├── CHANGELOG.md         # 版本变更记录
├── COPYING.md / LICENSE.BSD / LICENSE.MPL-2.0
├── join.go / vfs.go     # 旧 API SecureJoin
└── pathrs-lite/
    ├── doc.go           # package pathrs(注意包名!)
    ├── open.go / open_purego.go / open_libpathrs.go
    ├── mkdir.go / mkdir_purego.go / mkdir_libpathrs.go
    ├── procfs/          # /proc 安全访问封装
    └── internal/
        ├── assert/ fd/ gocompat/ kernelversion/ linux/
        ├── gopathrs/    # lookup_linux.go / mkdir_linux.go / open_linux.go / openat2_linux.go
        └── procfs/

doc.go 揭示了两个容易被忽略的事实:

  1. 包名是 pathrs 而不是 pathrslite。因此代码中通过 import "github.com/cyphar/filepath-securejoin/pathrs-lite" 引入后,实际以 pathrs.OpenInRoot(...) 方式调用;
  2. 文件带 //go:build linux 构建约束,这套 API 只支持 Linux

二、对外 API:五个函数解决"根内安全文件操作"

pathrs-lite 暴露的公共 API 非常收敛,全部集中在两个文件系列中:open*.go(打开)与 mkdir*.go(建目录)。这正是容器运行时最常面对的两类攻击面——路径穿越与 TOCTOU 竞态

2.1 打开文件:OpenInRoot / OpenatInRoot / Reopen

// open.go
func OpenInRoot(root, unsafePath string) (*os.File, error) {
	rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
	// ...
	return OpenatInRoot(rootDir, unsafePath)
}

open.go 的实现为起点,三个函数的分工如下:

函数 签名 语义
OpenInRoot OpenInRoot(root, unsafePath string) (*os.File, error) 以字符串形式的根目录打开其中任意路径
OpenatInRoot OpenatInRoot(root *os.File, unsafePath string) (*os.File, error) 根目录以 *os.File 句柄提供,保证多次调用落在同一个 rootfs
Reopen Reopen(handle *os.File, flags int) (*os.File, error) 通过 /proc/self/fd 把受限的 O_PATH 句柄"升级"为可用句柄

源码注释给出了 OpenInRoot 的"朴素但不安全"的对照实现:

path, _ := securejoin.SecureJoin(root, unsafePath)
handle, err := os.OpenFile(path, unix.O_PATH|unix.O_CLOEXEC)

这段代码的不安全之处正是 SecureJoin 的根本缺陷:如果攻击者在 SecureJoin 返回路径字符串与 os.OpenFile 真正执行之间篡改了文件系统树(例如把目标目录从 rootfs 内搬移到 rootfs 外),返回的文件就可能落在根目录之外——这是典型的 TOCTOU(time-of-check to time-of-use)攻击。pathrs-lite 的做法是全程基于目录句柄逐级解析,杜绝了"字符串被换"这一竞态窗口。

从源码结构看,真正的工作发生在 internal/gopathrslookup_linux.goopen_linux.goopenat2_linux.go 中——即所谓"纯 Go 解析器";而 pathrs-lite/procfs/ 目录则负责 /proc 的安全操作。

2.2 返回的是受限的 O_PATH 句柄,需要时用 Reopen 升级

一个重要的设计约束是:所有 API 返回的都是 O_PATH 句柄O_PATH 是 Linux 2.6.39+ 引入的"惰性打开"模式——它只获取对 inode 的引用,不真正打开文件,因此能安全地在路径上"行走",却对文件本身几乎不能做读写操作。

doc 注释解释了原因:只返回受限制的句柄,是为了避免"意外打开不受信任的文件导致问题"——例如打开一个已断开的 TTY 可能造成 DoS(O_PATH 打开不会唤醒这种设备,而普通 open 会)。需要真正使用文件时,应通过 Reopen 升级:

path, _ := pathrs.OpenInRoot(root, unsafePath)   // O_PATH 句柄
// 完成路径校验后,再以真实读写模式重新打开
handle, err := pathrs.Reopen(path, unix.O_RDONLY|unix.O_CLOEXEC)

在纯 Go 后端中,Reopen 的实现定义于 open_purego.go,内部通过 procfs.ReopenFd 完成,而 procfs 文档注释(procfs_purego.go)给出其等价语义:

// 等价于:
proc, _ := procfs.OpenProcRoot()
link, _ := proc.OpenThreadSelf(fmt.Sprintf("fd/%d", f.Fd()))
n, _ := unix.Readlinkat(int(link.Fd()), "", buf[:])

2.3 创建目录树:MkdirAll / MkdirAllHandle

// mkdir.go
func MkdirAll(root, unsafePath string, mode os.FileMode) error {
	rootDir, err := os.OpenFile(root, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
	// ...
	f, err := MkdirAllHandle(rootDir, unsafePath, mode)
	_ = f.Close()
	return nil
}

mkdir.goos.MkdirAll 的竞态安全替代品。源码注释特别强调了一个关键约束:

即使攻击者能把目录从 rootfs 内部移动到 rootfs 外部,新建的目录树可能落在 root 之外,但任何时刻我们都不会走出正在创建的目录树之外——os.MkdirAll 的朴素版本则可能在两次系统调用之间解析出不安全的符号链接组件,把目录建到 root 之外。

MkdirAllHandlemkdir_purego.go)比 MkdirAll 更优的两点:

  1. 由调用方以 *os.File(最好是 O_PATH 句柄)提供根目录,确保使用哪个 rootfs 完全由调用方掌控;
  2. 创建完成后直接返回最终目录的 O_PATH 句柄,且是"近乎无竞态"的(攻击者只能对最后一个路径组件做替换)。其文档还指出,这比"先 MkdirAll 再重新 lookup(SecureJoin / openat2)"高效得多——若你计划在创建目录后立即打开它,就应直接使用 MkdirAllHandle

2.4 与 SecureJoin 的关键语义差异

主模块 README(New API 章节)pathrs-lite 的实现完全一致地强调了一个行为差异:

  • SecureJoin 会把不存在的路径组件当作真实目录继续拼接,也允许悬空符号链接(dangling symlink)被部分解析;
  • OpenInRoot / MkdirAll 一旦遇到悬空符号链接或不存在的路径组件就立刻返回错误MkdirAll 也不会去为悬空符号链接所指向的目录建目录。

理由很直接:前一种宽容行为与 Linux 内核处理"不存在路径/悬空链接"的真实语义相违背,也是历史竞态问题的温床,因此新 API 一律不再允许。

三、双后端机制:一个 build tag 无缝切换 libpathrs

这是 pathrs-lite README 中最具特色的设计,也是它作为"过渡工具"的底气所在:

构建时若使用 libpathrs build tag,pathrs-lite 将直接使用 libpathrs 而非纯 Go 实现;两个后端功能等价(上游用集成测试保证这一点),因此迁移对用户无任何可见影响。

打开目录即可看到这一设计在文件系统层面的落地——成对出现的源文件:

纯 Go 后端(默认) libpathrs 后端(-tags libpathrs
open_purego.golinux && !libpathrs open_libpathrs.golibpathrs
mkdir_purego.golinux && !libpathrs mkdir_libpathrs.golibpathrs
procfs/procfs_purego.golinux && !libpathrs procfs/procfs_libpathrs.golibpathrs

对比两端的 OpenatInRoot 实现可以看出迁移的平滑度。libpathrs 后端(open_libpathrs.go)走的是 pathrs.RootFromFile + rootRef.Resolve(unsafePath) + handle.IntoFile();纯 Go 后端则调用 gopathrs.OpenatInRootReopen 同样如此:纯 Go 版用 procfs.ReopenFd,libpathrs 版用 pathrs.HandleFromFile + handle.OpenFile(flags)

对 Moby 这类大型仓库来说,这种"构建期开关"的价值在于:即使 pathrs-lite 只是被某个第三方包(如 buildkit)间接引用,最终产物也能在不写 CGo 的情况下,按需切换成真正的 libpathrs 实现,无需改动任何业务调用代码。

从构建实践看,切换方式是标准的 Go build tag 用法:

# 默认:纯 Go 实现(无需 CGo,二进制自包含)
go build ./...

# 切换:编译期启用 libpathrs 后端
go build -tags libpathrs ./...

四、实现纵深:openat2、内核回退与恶意 /proc 防御

主模块 README 明确说明了这些安全 API 的底层策略:机会性地使用更新版本的内核 API,以获取"字符串拼接方案"不可能提供的安全性。

4.1 openat2 与 RESOLVE_IN_ROOT

在足够新的内核(Linux 5.6+)上,路径查找会使用 openat2(2),通过其扩展属性实现两类关键能力:

  • 限制 lookup 穿越 magic-links(/proc/*/fd/* 这类链接)与 bind-mount;
  • RESOLVE_IN_ROOT 在 rootfs 内高效地解析符号链接——相当于让内核以类似 chroot 的方式解析路径,从根本上杜绝解析器"自己写循环"时引入的 bug。

internal/gopathrs/ 下的 openat2_linux.golookup_linux.go 中就承载着这套 openat2 解析逻辑。

4.2 无法使用 openat2 时的 O_PATH 回退

CHANGELOG.md 的 0.6.1 条目披露了一个非常实战的细节:

判断"是否使用 openat2(2),还是回退到 O_PATH 解析器"的逻辑原先会缓存探测结果……这导致当 pathrs-lite 被一个给自己应用了新 seccomp-bpf 过滤器的程序使用时,若过滤器拒绝了 openat2(2),会返回该错误而不是回退到 O_PATH 解析器。0.6.1 修复后,只有在 openat2 出错时才缓存该结果。

也就是说,纯 Go 后端内部维护着双路径策略:优先用 openat2,一旦内核过旧或系统调用被 seccomp 拦截,就回退到基于 O_PATH 的逐组件解析器(O_PATH 解析本身就是纯 Go 版的默认能力)。0.6.1 还顺手修复了 RESOLVE_IN_ROOT 需要 dup 时的一处 fd 泄漏。

4.3 对恶意 /proc 的加固(procfs 子包)

/proc/self/fdReopen 的必经之路,而容器运行时的威胁模型里,/proc 本身可能是被攻击者配置过的。procfs 相关代码以 CVE-2019-19921CVE-2024-21626 为反例,明示了这类攻击的现实性:

  • CVE-2019-19921:通过被操纵的 /proc 配置攻击文件操作(源码注释见 open_purego.go);
  • CVE-2024-21626:容器内 WORKDIR/cd 场景经由 /proc/self/fd 逃逸到宿主机文件系统的经典漏洞(procfs_purego.go)。

procfs_purego.go 提供了分级的 /proc 访问 API,全部围绕"如何安全地使用 procfs"展开:

  • OpenProcRoot():优先打开带 subset=pid 挂载选项的"更安全" /proc(Linux 5.8+),失败时回退普通 /proc
  • OpenUnsafeProcRoot():不加任何 overmount/mask 的 /proc,文档警告它绝不能泄漏进容器,且使用后应立即关闭,以避免已知 fd 编号攻击
  • OpenThreadSelf(subpath) / OpenSelf(subpath) / OpenPid(pid, subpath) / OpenRoot(subpath):四类访问点,各自对应不同的信任等级。OpenThreadSelf 专门处理 Go 语言中 goroutine 与线程不一一对应的问题——它保证句柄指向调用方所在线程,并要求配合 runtime.UnlockOSThread 使用;
  • ProcSelfFdReadlink(f):通过 /proc/self/fd/<fd> 的 readlink 获取文件真实路径的快捷封装。

主模块 README 还提到,特权用户会额外获得一层保护:使用 fsopen(2)open_tree(2)(Linux 5.2+)来避免被伪造的 /proc 欺骗。从源码结构可以推断,这些加固逻辑分布在 internal/procfsinternal/linux 等 internal 目录中——它们全部位于 internal/ 之下,意味着对外不可见、也不构成公共 API,防止用户误用底层细节。

五、在 Moby 中的引用现状

回到本仓库本身,pathrs-lite 目前是作为间接依赖被引入的:

  • go.mod 中声明:github.com/cyphar/filepath-securejoin v0.6.1 // indirect
  • vendor/modules.txt(第 655 行起)记录了完整的模块与包清单,其中 pathrs-lite 及其 internal/* 全部被 vendored;
  • 在本仓库的 Moby 源码树(daemon、pkg、internal、integration 等)中,当前快照并未发现直接 import ... filepath-securejoin 的代码;实际引用方是 Moby 自身 vendored 的 buildkitvendor/github.com/moby/buildkit/cache/contenthash/checksum.gopath.go 均导入了 cyphar/filepath-securejoin,用于内容寻址时对挂载根内的路径做安全解析。

这意味着:Moby 官方源码暂时只把该模块作为构建链上的传递依赖引入(间接方式),并未直接调用其 API;而它 vendored 的 buildkit 内容哈希代码是实际使用者之一。若读者希望在自己的 Go 项目中直接体验,可以像上面第二、三节所示,import "github.com/cyphar/filepath-securejoin/pathrs-lite" 并仅在 Linux 上使用(该包带有 linux 构建约束)。

六、使用注意事项小结

综合 README 与源码,使用 pathrs-lite 时有几条容易被忽略的约束,这里集中列出:

  1. 仅限 Linux:所有实现文件都带 //go:build linux 约束,跨平台项目需要自行按平台隔离;
  2. 句柄是受限的 O_PATH:不要指望直接对返回值做读写操作,先想清楚是否要通过 Reopen 升级、用什么 flags 升级;
  3. 不要拿返回路径字符串去二次 openpathrs-lite 的全部价值就在于"句柄内解析",若把结果转成字符串再交给普通 os.OpenFile,等于把 TOCTOU 窗口重新打开;
  4. 悬空链接与不存在路径会直接报错:这与老 SecureJoin 的宽容语义不同,迁移代码时需要适配错误处理;
  5. 创建即打开的目录用 MkdirAllHandle:既能拿到近乎无竞态的目标目录句柄,又避免了二次 lookup 的开销;
  6. 依赖内核版本的行为分层:openat2(Linux 5.6+)与 O_PATH 回退策略并存,运行时还会受 seccomp 策略影响,必要时参考 CHANGELOG.md 中关于内核探测与回退的修复记录。

七、许可证信息

pathrs-lite README 的 License 小节说明:该子包绝大多数代码采用 Mozilla Public License 2.0(MPL-2.0),版权归属于 Aleksa Sarai(cyphar)与 SUSE LLC(2024-2025)。具体依据可查看仓库内的 COPYING.mdLICENSE.MPL-2.0,每个源文件头部也带有独立的 SPDX 许可头(如 // SPDX-License-Identifier: MPL-2.0)。作为对照,filepath-securejoin 主模块采用 BSD-3-Clause 与 MPL-2.0 双许可组合,其中新 API 相关文件多来自 libpathrs,故归入 MPL-2.0(见主 README.md)。在 Moby 这种需要对外分发二进制、又按 vendor 方式整树引入依赖的场景下,MPL-2.0 的弱 Copyleft 特性与源码头部逐文件标注的方式,为其合规审计提供了便利。

登录后查看全文
热门项目推荐
相关项目推荐