15、竞争条件与TOCTOU:时间-of-check-time-of-use漏洞
竞争条件,说白了就是「谁先谁后」的问题。多线程或多进程环境下,两个操作在时间上产生了冲突,结果就不可控了。我刚开始接触这类漏洞时,总觉得是概率问题,碰上了算倒霉。后来在线上环境吃过一次亏,才明白——这不是运气,是设计缺陷。
15.1 TOCTOU 漏洞的本质
TOCTOU 全称是 Time-of-Check Time-of-Use,检查时刻与使用时刻不一致。你检查完一个资源的状态,等真正去用它时,状态已经变了。
举个例子:
// 检查文件是否存在
if (access("/tmp/config", R_OK) == 0) {
// 此时文件被攻击者替换成了符号链接
// 指向 /etc/shadow
FILE *fp = fopen("/tmp/config", "r");
// 读取的内容根本不是原来的文件
}
这段代码的问题在哪?access() 和 fopen() 之间有时间窗口。攻击者可以利用这个窗口,把文件替换掉。我在项目中遇到过类似的场景,当时是检查日志文件权限后打开写入,结果被低权限进程篡改了路径。嗯,从那以后我再也不敢在检查和使用之间留空档了。
15.2 文件操作中的竞争条件
文件操作是 TOCTOU 的重灾区。为什么?因为文件系统本身是共享资源,多个进程可以同时操作同一个路径。
常见的危险模式:
- 检查存在性 → 创建文件:先判断文件不存在,再创建。中间可能被别人抢先创建。
- 检查权限 → 打开文件:先检查可写,再打开。权限可能被改掉。
- 检查类型 → 读取内容:先判断是普通文件,再读取。可能被替换成管道或设备文件。
我见过一个真实案例:某程序先检查临时目录下是否有锁文件,没有就创建。攻击者不断删除并重建同名文件,最终让程序把锁文件指向了关键系统文件。后果?整个服务崩溃。
正确的做法是什么?用原子操作。比如 open() 加上 O_CREAT | O_EXCL 标志,创建文件时如果已存在就直接失败。这样检查和创建是原子操作,没有时间窗口。
// 安全的文件创建方式
int fd = open("/tmp/lock", O_CREAT | O_EXCL | O_RDWR, 0600);
if (fd == -1) {
// 文件已存在,说明有其他进程在操作
perror("open");
exit(EXIT_FAILURE);
}
// 此时文件一定是我们创建的
write(fd, "locked", 6);
close(fd);
open() 配合原子标志,而不是先 access() 再 fopen()。少一次检查,少一个漏洞。
15.3 信号处理中的竞态
信号处理函数里的竞争条件,很多人容易忽略。信号是异步的,随时可能中断主程序的执行。如果信号处理函数和主程序操作同一个全局变量,问题就来了。
举个例子:
volatile sig_atomic_t flag = 0;
void handler(int sig) {
flag = 1; // 设置标志
}
int main() {
signal(SIGINT, handler);
while (!flag) {
// 主循环
}
// 清理工作
printf("收到信号,退出\n");
return 0;
}
这段代码看起来没问题?其实有隐患。如果信号在 while 条件判断之后、循环体执行之前到达,flag 被置为 1,但循环体已经执行完了。下次判断时才会退出。这不算严重,但如果你在信号处理函数里调用了不可重入函数,比如 malloc()、printf(),那就可能直接崩溃。
我曾经调试过一个诡异的问题:程序偶尔在收到 SIGTERM 时挂死。查了半天,发现信号处理函数里调用了 free(),而主程序当时也在 malloc() 内部。两个操作冲突了,堆结构被破坏。
_Exit()、调用 signal()。其他操作都不安全。
15.4 原子操作与锁
解决竞争条件,最直接的手段就是原子操作和锁。原子操作保证一条指令完成读写,不会被中断。锁保证临界区只有一个线程进入。
C11 标准提供了 <stdatomic.h>,里面定义了原子类型和操作:
#include <stdatomic.h>
atomic_int counter = 0;
void increment() {
atomic_fetch_add(&counter, 1);
}
int main() {
// 多线程环境下,counter 的值一定是正确的
increment();
printf("%d\n", atomic_load(&counter));
return 0;
}
对于更复杂的临界区,需要用互斥锁:
#include <pthread.h>
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int shared_data = 0;
void safe_update() {
pthread_mutex_lock(&lock);
shared_data++; // 临界区
pthread_mutex_unlock(&lock);
}
你想想看,锁的本质是什么?就是把「检查」和「使用」合并成一个不可分割的操作。这正是对抗 TOCTOU 的核心思路。
15.5 知识体系总览
下面这张图总结了本章的核心逻辑:
这张图把竞争条件的三个主要场景串起来了。TOCTOU 是根源,文件操作和信号处理是典型战场,原子操作和锁是最终武器。你写代码时,只要遇到「先判断再执行」的模式,就想想这张图——有没有时间窗口?能不能用原子操作替代?