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() 之间有时间窗口。攻击者可以利用这个窗口,把文件替换掉。我在项目中遇到过类似的场景,当时是检查日志文件权限后打开写入,结果被低权限进程篡改了路径。嗯,从那以后我再也不敢在检查和使用之间留空档了。

⚠️ 注意: TOCTOU 不是理论漏洞。它在文件系统、共享内存、设备驱动中都很常见。只要存在「先检查后使用」的模式,就可能有风险。

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() 内部。两个操作冲突了,堆结构被破坏。

核心原则: 信号处理函数里只能做三件事——设置 volatile sig_atomic_t 标志、调用 _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 的核心思路。

💡 避坑指南: 我曾经在项目中用自旋锁保护一个很短的临界区,结果在高并发下 CPU 占用率飙升。后来换成互斥锁,问题解决。选锁要看场景——临界区短且不频繁,用自旋锁;临界区长或可能阻塞,用互斥锁。

15.5 知识体系总览

下面这张图总结了本章的核心逻辑:

竞争条件与 TOCTOU 知识体系 竞争条件 TOCTOU 漏洞 文件操作竞争 信号处理竞态 检查与使用时间窗口 原子创建 / O_EXCL volatile sig_atomic_t 原子操作 + 互斥锁

这张图把竞争条件的三个主要场景串起来了。TOCTOU 是根源,文件操作和信号处理是典型战场,原子操作和锁是最终武器。你写代码时,只要遇到「先判断再执行」的模式,就想想这张图——有没有时间窗口?能不能用原子操作替代?

总结一句话: 竞争条件不是玄学,是设计时没考虑「别人也在动」。用原子操作把检查和执行合并,用锁保护临界区,用 volatile sig_atomic_t 处理信号。做到这三点,大部分竞态问题都能避免。

公众号:蓝海资料掘金营,微信 deep3321