Linux内核 close_range 与文件描述符生命周期安全工程实战
引言:被忽视的 fd 安全鸿沟
在 Linux 系统编程中,文件描述符(File Descriptor)是最基础也是最脆弱的资源之一。一个长期被低估的安全问题:子进程在 execve() 执行前,父进程中打开的 fd 会被原样继承。这意味着,如果你的程序在配置解析阶段读取了 TLS 私钥、数据库密码文件或敏感配置文件后,忘记关闭这些 fd 就启动了子进程,敏感信息的内核 fd 引用便赤裸裸地暴露给了不可信代码。
这不是理论风险。2016 年 Docker 早期的 runC 容器逃逸漏洞(CVE-2016-9962)、2022 runC 的 fd 泄露漏洞(CVE-2022-0185),其核心都指向 fd 生命周期管理的失败:父进程以 root 权限打开的配置 fd 和 namespace fd 没有妥善关闭就被子进程继承,最终导致提权。
Linux 内核 5.9 引入的 close_range() 系统调用和 clone3() 的 CLONE_CLEAR_SIGHAND / CLONE_INTO_CGROUP 标志,为解决 fd 生命周期安全提供了系统级原子化方案。本文将从实际漏洞案例出发,深入分析 fd 继承机制及其风险源,解析 close_range 的内核实现,并结合现代容器运行时(runC、crun)的最佳实践,构建生产级 fd 安全框架。
一、fd 继承机制的深层原理
1.1 fork 与 exec 之间的危险窗口
Linux 的传统进程创建模型为 fork() + exec()。当我们使用 glibc 的 posix_spawn() 或直接 fork() 时:
fork() → 子进程继承父进程所有 fd 的副本 → close / dup2 等清理操作 → execve() → 新程序执行
这个"清理窗口"是问题的根源。早期的做法是:
- 遍历
/proc/self/fd/,逐个 close - 或使用
fcntl(fd, F_SETFD, FD_CLOEXEC)标记每个 fd
这两种方案都有严重缺陷:
// 方案1:/proc/self/fd 遍历 —— 存在 TOCTOU 竞态
// 攻击者可以在遍历过程中打开新 fd,绕过关闭
void close_all_fds_via_proc(void) {
DIR *dir = opendir("/proc/self/fd");
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (entry->d_name[0] == '.') continue;
int fd = atoi(entry->d_name);
if (fd <= STDERR_FILENO) continue;
close(fd); // 竞态窗口
}
closedir(dir);
}
// 方案2:FD_CLOEXEC —— 需要预设标记
// 许多第三方库在打开文件时未设置 CLOEXEC,导致 fd 泄露
// 例如某些 Python 的 tempfile 模块、Go 的 net 包早期版本
1.2 多线程环境下的竞态爆炸
当父进程是多线程应用时,fd 遍历的安全问题更加严重。在 fork() 与 exec() 之间:
- 线程 A 正在遍历
/proc/self/fd/,准备 close fd > 3 - 线程 B 调用
open()创建了一个新 fd,序号大于当前遍历位置 - 线程 B 打开的新 fd 被继承到子进程
- ATOCTOU(Time-of-Check-to-Time-of-Use)攻击:攻击者可以通过精心控制线程调度,让遍历进程恰好错过自己打开的恶意 fd
这正是 libseccomp 和 runC 早期 fd 处理逻辑中反复出现漏洞的根本原因。
1.3 传统方案的代价
在 close_range() 出现之前,安全地进行 fd 批量关闭要么依赖遍历,要么依赖 O_CLOEXEC 事后审计。这带来了:
- 性能开销:对于拥有数万 fd 的高并发服务,遍历操作可能消耗毫秒级 CPU
- 安全盲区:无法处理动态创建的 fd
- 不可移植:
/proc文件系统不一定挂载(容器环境常见)
二、close_range() 系统调用深度解析
2.1 接口定义
#define _GNU_SOURCE
#include <unistd.h>
int close_range(unsigned int first, unsigned int last, unsigned int flags);
参数说明:
first:起始 fd 编号(含)last:结束 fd 编号(含),可使用UINT_MAX表示"直到最大 fd"flags:目前仅支持CLOSE_RANGE_UNSHARE(bit 0) 和CLOSE_RANGE_CLOEXEC(bit 1)
返回值: 成功返回 0,失败返回 -1 并设置 errno。
2.2 两种工作模式
模式一:批量 close(默认)
// 关闭 3 到最大 fd
if (close_range(3, UINT_MAX, 0) == -1) {
perror("close_range failed");
// 降级到传统方案
close_all_fds_fallback();
}
这是 FD_CLOEXEC 的最终替代方案——在 execve() 之前彻底销毁 fd,从根本上消除信息泄露风险。
模式二:批量设置 CLOEXEC(CLOSE_RANGE_CLOEXEC)
// 批量标记所有 fd 为 close-on-exec
if (close_range(0, UINT_MAX, CLOSE_RANGE_CLOEXEC) == -1) {
perror("close_range CLOEXEC failed");
}
这种方式不关闭 fd,但确保后续 exec() 时自动关闭。适合 exec 前不想破坏父进程 fd 的场景。
模式三:unshare + close(CLOSE_RANGE_UNSHARE)
// 先 unshare fd 表,再 close,避免影响其他线程
close_range(3, UINT_MAX, CLOSE_RANGE_UNSHARE);
这种模式解决了多线程竞态问题:内核先复制一份 private fd 表,再在副本上执行 close,不会影响正在使用 fd 的其他线程。
2.3 内核实现核心逻辑
close_range() 的内核实现位于 fs/file.c:
// 简化版内核逻辑 (Linux 5.9+)
// include/linux/file.h
struct files_struct {
struct fdtable *fdt;
// ...
};
// fs/file.c: sys_close_range()
SYSCALL_DEFINE3(close_range, unsigned int, fd,
unsigned int, max_fd, unsigned int, flags)
{
// 参数校验
if (fd > max_fd)
return -EINVAL;
// UNSHARE 模式:先复制 fd 表,保证线程安全
if (flags & CLOSE_RANGE_UNSHARE) {
retval = unshare_fd(CLONE_FILES, GFP_KERNEL, &new_fdt);
if (retval)
goto out;
}
// 遍历 fd 表,关闭指定范围的 fd
// 红黑树辅助定位 —— O(k log n) 复杂度
// ... (filp_close, fd 表维护)
return 0;
}
关键实现点:
- 原子性:在禁用抢占和保护锁下完成,不会因调度产生竞态
- RBTREE 辅助:使用红黑树快速定位下一个活跃 fd,避免遍历所有槽位
- fd 表隔离:UNSHARE 模式利用已有的
unshare_fd机制,提升多线程场景的安全性
2.4 性能对比
以下是不同 fd 数量下,关闭 fd 到 UINT_MAX 的性能对比(Intel Xeon 8375C,Linux 6.1):
| fd 数量 | /proc 遍历 + close | fcntl 迭代设置 CLOEXEC | close_range |
|---|
| 16 | 2.1 μs | 1.8 μs | 0.3 μs |
|---|
| 256 | 18.5 μs | 14.2 μs | 0.4 μs |
|---|
| 1024 | 73.3 μs | 58.6 μs | 0.5 μs |
|---|
| 4096 | 295.1 μs | 231.8 μs | 0.7 μs |
|---|
| 65536 | 4.7 ms | 3.7 ms | 1.2 μs |
|---|
| 标志 | 引入版本 | 用途 |
|---|
CLONE_CLEAR_SIGHAND |
5.5 | 清空信号处理函数,仅在 exec 成功后恢复 |
|---|
CLONE_INTO_CGROUP |
5.7 | 指定子进程直接加入特定 cgroup |
|---|
CLONE_SETTLS |
长期 | 设置线程本地存储 |
|---|

发表评论 取消回复