TOCTOU竞态漏洞详解与防护
特权程序写临时文件、先access再open、多线程里if (balance >= x)再扣款——检查(Check)与使用(Use)之间若可被他人插入修改,就是TOCTOU(Time-of-Check to Time-of-Use):一类经典的竞态条件(Race Condition)与安全漏洞,在文件系统、权限校验、共享状态里极常见。
速览
- 本质:Check 与 Use不是原子操作;攻击者在窗口内换掉资源,程序按过期假设行动。
- 文件系统重灾区:
access()+open()、先判断存在再create、符号链接切换。 - 首选防护:原子完成「检查+使用」——
O_CREAT|O_EXCL、对fd操作而非路径。 - 原则:用操作失败代替预检查;
O_NOFOLLOW;特权程序禁用access()做安全决策。 - 内存竞态:锁或CAS包裹整个 check-use 临界区。
核心结论(30 秒版)
- 不要 Check 再 Use——尽量Atomic 地 Do。
- 临时文件:
open(..., O_CREAT|O_EXCL|O_WRONLY, 0600)。 - 打开后
fstat(fd)/fchmod(fd),别再信任路径名。 - setuid 程序:永不
access()+ 特权open()。 - 多线程:
if检查与赋值必须同一互斥区或原子指令。
决策树(我在防什么?)
Check 与 Use 是否分离? ├─ 是,且中间可被他人/他线程改变资源 │ ├─ 文件路径 → O_EXCL / open+fd / O_NOFOLLOW │ ├─ 共享变量 → mutex 或 CAS 包裹整段 │ └─ 业务状态 → 数据库事务 / 乐观锁版本号 └─ 否(单次原子 syscall 或锁内完成)→ 一般无 TOCTOU目录
- 1. 什么是 TOCTOU
- 2. 文件系统经典案例
- 3. 常见场景
- 4. 为何难发现
- 5. 防护策略总览
- 6. 文件系统安全写法
- 6.6 防护边界:难以 100% 杜绝
- 7. 特权程序与多线程
- 8. 各语言 API 速查
- 9. 常见误区
- 10. 延伸阅读
1. 什么是 TOCTOU
TOCTOU=Time-of-Check(检查时刻)与Time-of-Use(使用时刻)之间的缝隙。
程序先验证某条件(文件可写、不存在、权限合法、余额足够),在验证通过之后、真正使用该资源之前,另一线程/进程/用户改变了资源,导致操作对象与检查时不一致。
| 概念 | 说明 |
|---|---|
| Check | 基于当前快照的判断(存在性、权限、类型) |
| Use | 打开、读写、删除、扣款等实际副作用 |
| 根因 | 两次操作非原子;中间状态可变 |
| 别名 | Check-Then-Act、Double-Fetch(安全语境) |
2. 文件系统经典案例
2.1access()+open()(UNIX symlink 攻击)
if(access("important.txt",W_OK)==0){/* Check:当前用户可写? */fd=open("important.txt",O_WRONLY);/* Use:打开写入 *//* write(fd, ...) */}攻击窗口:access成功后、open前,攻击者把important.txt换成指向/etc/passwd的符号链接。若程序以elevated 权限运行,open可能按链接目标写入系统文件。
- Check 通过的是原路径当时的权限
- Use 操作的是已被替换的对象
2.2 存在性检查再创建
if(!file_exists(path)){create_file(path);}在「不存在」与「创建」之间,攻击者可:
- 创建同名文件或符号链接
- 导致覆盖敏感文件、权限逃逸、写入错误目标
2.3 临时目录预测攻击
在/tmp等全局可写目录用可预测文件名(/tmp/app.pid):
- 程序检查「文件不存在」
- 攻击者抢先
mkfifo/symlink - 程序创建或打开时踩坑
历史上sudo、glibc tmpfile等 CVE 多与临时文件 TOCTOU 相关。
3. 常见场景
| 场景 | Check → Use 模式 |
|---|---|
| 文件系统 | 权限/存在/类型检查 →open/rename/unlink |
| setuid 程序 | 用调用者身份access→ 用 root 身份open |
| 多线程 | if (x > 0)→ 修改x |
| 临时文件 | 不存在 →creat/open |
| 配置/锁 | 校验版本或签名 → 加载内容 |
| 业务逻辑 | 查库存/余额 → 下单扣减 |
4. 为何难发现
| 原因 | 说明 |
|---|---|
| 低概率 | 窗口极窄,功能测试常碰不到 |
| 需并发 | 单线程、低负载难复现 |
| 跨 syscall | 静态分析难追踪两次调用间的时序 |
| 环境依赖 | 本地难现,生产/多用户目录才暴露 |
窗口有多窄?在 Linux 上,Check 与 Use 之间往往只有纳秒~微秒级的 CPU 时间;看似「来不及插一脚」。但在多核、抢占式调度、页错误、上下文切换下,另一线程/进程仍可能插入——本地单线程压测难复现,生产多用户、高负载、共享/tmp时概率显著上升。这也解释了「为什么大家还在犯」:功能测试几乎永远绿灯。
模糊测试、并发压力测试、Code Review 关注Check-Then-Act模式,比纯单元测试更有效。
5. 防护策略总览
| 策略 | 要点 |
|---|---|
| 原子化 | 一次 syscall 完成 check+use(O_EXCL等) |
| 用 fd 不用 path | open后fstat/read,不再解析路径 |
| 失败代替预判 | 直接open,用errno处理 |
| 禁跟 symlink | O_NOFOLLOW、受控工作目录 |
| 锁/事务 | flock、DB 事务、mutex 包住 check+use |
| 最小权限 | setuid 尽早降权;不在用户可控目录操作 |
口诀:不要 Check 再 Use,尽量 Atomic 地 Do;用 fd 不用路径,用失败代替预判;禁 symlink,降权限,锁住临界区。
6. 文件系统安全写法
6.1 安全创建(消除「存在性检查 + 创建」)
intfd=open(path,O_CREAT|O_EXCL|O_WRONLY,0600);if(fd<0){if(errno==EEXIST){/* 已存在,按业务处理 */}/* 其他错误 */}O_EXCL与O_CREAT同用:仅当文件不存在时创建,内核原子完成,无 TOCTOU 窗口。
临时文件更推荐:
/* POSIX:mkstemp 模板由内核生成唯一名 */chartmpl[]="/tmp/app-XXXXXX";intfd=mkstemp(tmpl);6.2 打开后信任 fd,而非路径
intfd=open(path,O_RDONLY|O_NOFOLLOW);if(fd<0){/* 处理错误 */}structstatst;if(fstat(fd,&st)!=0){close(fd);return-1;}/* 校验 st.st_uid、st.st_mode、是否为常规文件等 *//* 后续 read/write/fchmod 均针对 fd */避免access()+open();打开后用fstat(fd)校验类型/属主/权限,后续用fchmod(fd)/fchown(fd)操作已打开对象,勿再解析可能被换链的路径。
6.3 禁止符号链接跟随与openat工作目录
fd=open(path,O_RDONLY|O_NOFOLLOW);在攻击者可控目录(如/tmp、用户 home 下随机路径)操作时:
- 使用
O_NOFOLLOW - 避免 TOCTOU 间路径被换链
容器 / setuid / sandbox 场景推荐:先打开受控目录为dirfd,再相对打开其内文件,减少路径穿越与目录级竞态:
intdfd=open("/var/run/myapp",O_PATH|O_DIRECTORY|O_CLOEXEC);if(dfd<0){/* 错误 */}intfd=openat(dfd,"conf",O_RDONLY|O_NOFOLLOW|O_CLOEXEC);/* 校验 fd 后读写;dfd 指向的目录权限须仅 root/服务可写 */比「裸路径 +O_NOFOLLOW」更稳:工作目录本身不可被攻击者替换(权限0700、非 world-writable)。
6.4 文件锁的局限
flock/fcntl锁可序列化同一文件上的 check+use,但不能防止「Check 的是路径 A、Use 前 A 被换成指向 B 的 symlink」——锁的是 inode,路径级 TOCTOU 仍要靠O_EXCL/O_NOFOLLOW/fd 语义。
6.5 避免access()做安全决策
/* 不推荐 */if(access(path,R_OK)==0)fd=open(path,O_RDONLY);/* 推荐 */fd=open(path,O_RDONLY);if(fd<0){/* EACCES、ENOENT 等 */}access()使用真实 UID(real uid)判断权限,而 setuid 程序里open()往往按有效 UID(effective uid)执行——二者不一致时,会出现「access 说不可写、open 却以 root 写成功」或相反的逻辑漏洞。
6.6 防护边界:难以 100% 杜绝
即使使用O_EXCL、O_NOFOLLOW、openat,在以下情况仍可能有TOCTOU 变种:
| 条件 | 风险 |
|---|---|
| 路径位于攻击者可控文件系统 | NFS、FUSE、恶意挂载等上的「原子创建」语义可能与本地 ext4 不同 |
| 目录级竞态 | rename/RENAME_EXCHANGE等在 Check 与 Use 间换掉目录项 |
| 工作目录可被写 | world-writable 的/tmp上操作可预测文件名 |
内核保证的是同一本地、可信文件系统内单次 syscall 的原子性;真正可靠的前提是:操作目录攻击者无法写入,且尽量在受控dirfd内用相对路径打开。安全编码能大幅降险,但不能替代「别把特权程序放进用户目录」的部署原则。
7. 特权程序与多线程
7.1 setuid / 高权限程序
| 原则 | 说明 |
|---|---|
不用access()决策 | 检查与 open 的 uid 语义可能不同 |
| 尽早 drop privilege | 能降权再操作不可信路径 |
| 受控目录 | 如/var/run/myapp-<uid>/,权限严格 |
| 直接 try + errno | 失败即拒绝,不预检 |
7.2 内存中的 TOCTOU(与数据竞争)
/* 错误:check 与 use 分离 */if(balance>=withdraw){balance-=withdraw;}严格说,这是 C/C++ 内存模型下的数据竞争(Data Race);从「检查–使用非原子」抽象看,它同样是Check-Then-Act / TOCTOU在内存中的表现。修复思路一致:别让 if 和修改之间留缝。
另一线程可在if与-=之间也扣款,导致超扣或负余额。
修复:
/* 互斥锁包裹整段 check+use */pthread_mutex_lock(&mu);if(balance>=withdraw)balance-=withdraw;pthread_mutex_unlock(&mu);或使用原子 CAS(无锁账户、无锁队列):
/* 伪代码:仅当 balance >= w 时原子减 w */atomic_compare_exchange_weak(&balance,&expected,expected-withdraw);关键:检查与修改必须在同一原子边界内(锁、CAS、DBUPDATE ... WHERE balance >= ?)。
7.3 数据库与业务层
SQL 事务、SELECT ... FOR UPDATE、乐观锁版本号本质是消除业务 TOCTOU:
UPDATEaccountsSETbalance=balance-100WHEREid=1ANDbalance>=100;affected_rows == 0表示 check 失败,无需先SELECT再UPDATE两段式竞态。
8. 各语言 API 速查
| 语言 | 安全临时文件 / 独占创建 |
|---|---|
| C | mkstemp、open(..., O_CREAT|O_EXCL, 0600) |
| C++ | std::filesystem仍要注意;优先mkstemp封装或库 |
| Python | tempfile.NamedTemporaryFile、tempfile.mkstemp |
| Java | Files.createFile(path, CREATE_NEW)、CREATE_NEW+NOFOLLOW_LINKS |
| Go | os.OpenFile(name, os.O_CREATE|os.O_EXCL, 0600);os.CreateTemp |
| Rust | tempfile::NamedTempFile、OpenOptions::new().create_new(true) |
9. 常见误区
| 误区 | 事实 |
|---|---|
| 「检查过就安全」 | 检查的是过去快照,Use 时对象可能已变 |
access通过就能写 | setuid + symlink → 经典 CVE 模式 |
| 文件锁万能 | 防不住路径被换链的跨对象 TOCTOU |
| 只有安全软件才用 | 任何check-then-act业务都有 |
| 单线程无 TOCTOU | 仍有信号处理程序、异步回调重入窗口 |
/tmp随便命名 | 用不可预测名+O_EXCL/mkstemp |
10. 延伸阅读
| 资源 | 说明 |
|---|---|
| CWE-367 | Time-of-check Time-of-use Race Condition |
| CWE-362 | Concurrent Execution using Shared Resource (Race) |
| CVE-2021-3156(sudo 堆溢出;常与特权程序安全一并讨论) | 特权边界案例 |
| Qualys 等对sudo / glibc 临时文件历史分析 | 临时目录 TOCTOU 类漏洞复盘 |
man 2 open/man 2 openat | O_EXCL、O_NOFOLLOW、O_PATH |
| CERT C FIO45-C | 避免 TOCTOU 竞态的文件操作 |
Code Review 时搜索模式:access(、stat(+open(、if.*exists+create、if (+ 无锁写共享变量——对每一处追问:Check 与 Use 之间谁还能改?
收束(技术):TOCTOU 的本质是把非原子的两步当成一步的信任;文件系统用O_EXCL 与 fd,并发用锁或 CAS,业务用事务与条件更新——让「检查」和「使用」在同一条不可打断的语义里完成。
一句话记法(人话):别问「能不能做」,直接去做,然后处理失败——与「用失败代替预判」同义,更适合口头传播。
验证环境:POSIX.1 · Linux · glibc · C11 线程 · Java NIO.2