竞争条件漏洞是由于多个进程或线程并发访问同一共享资源时,因未对执行顺序进行有效同步,导致系统最终状态依赖于不可预测的“执行时间差”。当攻击者通过精确计时的并发请求,在系统完成逻辑校验与执行状态改变之间的“时间窗口”内插入恶意操作时,就会引发系统行为失常。
1. 概述
竞争条件漏洞是多线程、多进程或分布式并发环境下最隐蔽、最危险的逻辑缺陷之一。它不像 SQL 注入或 XSS 那样具有明显的特征码,而是由于多个执行流对共享资源进行非原子性的读写操作,导致系统的安全性依赖于不可控的执行顺序或时间差。
最经典的原理可以用一句话概括:系统在“检查(Check)”到“使用(Use)”之间存在一个未受保护的时间窗口(Window of Vulnerability)。
1.1 漏洞本质
竞争条件漏洞的本质是:系统的安全性依赖于多个并发事件的执行顺序,而这个顺序是攻击者可以通过技术手段操控的。
在安全学术界,这种漏洞通常被模型化为 TOCTOU(Time-of-Check to Time-of-Use,检查时到使用时) 缺陷。
1.2 经典生命周期模型
任何敏感的业务操作,在代码底层都可以解构为两个阶段:
- TOC (Time-of-Check):安全状态校验阶段(如:是否有权限?余额够不够?优惠券是否未用过?);
- TOU (Time-of-Use):状态改变与结果输出阶段(如:扣除余额、生成订单、写入文件);
1.3 “时间窗口”的产生
在 Check 完成到 Use 开始之间,存在一个短暂的时间间隙,这就是漏洞窗口:
- 在单线程环境下,这个窗口是安全的;
- 在无并发控制的多线程环境下,攻击者通过在极短时间内发送海量请求,迫使多个请求的
Check阶段在同一个时间窗口内重叠,从而绕过后续的Use阶段限制;
2. 核心触发条件
产生竞争条件漏洞必须同时满足以下三个要素:
- 并发执行:系统至少存在两个同时运行的执行流(如多线程、多进程、多并发 HTTP 请求);
- 共享资源:多个并发流共同访问(读写)同一个对象(如数据库记录、内存、文件系统、全局变量);
- 非原子操作:业务逻辑在系统底层被拆分为多个独立指令(例如先查询状态、校验权限,再更新数据),指令之间存在可被抢占的时间窗口,且至少有一个执行流会对该共享资源进行写操作或改变其状态;
CPU 指令交错演示(以 count++ 为例):
高级语言中看似只有一行的代码 count++,在 CPU 汇编层级实际上包含 3 个独立指令:
- Read:从内存将
count的值加载到 CPU 寄存器; - Modify:在寄存器中将数值加 1;
- Write:将寄存器中的新值写回内存;
假设初始值 count = 100,当线程 A 和线程 B 同时执行该代码时,由于操作系统 CPU 调度是抢占式的,其指令执行顺序极易发生打乱:
| 时间序列 | 线程A | 线程B | 内存中的count | 状态解析 |
| T1 | 读取 count (100) | 100 | 线程 A 读取旧值 | |
| T2 | 被系统暂停,发生上下文切换 | 读取 count (100) | 100 | 线程 B 读取同一旧值 |
| T3 | 计算 100+1 并写回 | 101 | 线程 B 完成更新 | |
| T4 | 恢复运行,计算 100+1 并写回 | 101 | 线程 A 覆盖了线程 B 的计算结果 |
两次累加最终结果仅为 101(预期应为 102),发生了典型的脏写与丢失更新:
- 临界区与时间窗口:从读取资源状态到最终写入结果之间的这段时间被称为“时间窗口”。在此期间,共享资源的状态已经发生了变化,但执行流仍基于旧假设运行;
- 攻击者如何强化漏洞:攻击者通常无法直接改变 CPU 调度,但可以通过高并发请求刷包、利用高 CPU/磁盘 I/O 负载拉长执行时间、或引入网络延时等手段,大幅扩大时间窗口,从而将微秒级的竞争成功率提升到近乎 100%;
3. 常见的三种类型
- 1. 业务逻辑层竞态(Web / API 并发越权):
- 典型场景:优惠券超领、账户余额超提、商品超卖、重复兑换码;
- 漏洞原理:多个并发请求在事务未提交前共同读取到“余额/数量足够”的快照,各自通过校验后同时执行扣减,绕过数量限制;
- 2. 文件系统层竞态(TOCTOU)
- 典型场景:临时文件创建、文件权限校验;
- 漏洞原理:程序在校验文件权限(Check)与真正读写文件(Use)之间存在微秒级延迟。攻击者在此间隙替换文件路径或建立软链接(Symlink),导致程序误操作敏感文件(如
/etc/shadow);
- 3. 内存与线程级竞态(Memory Safety Races)
- 典型场景:C/C++ 多线程内存管理、高并发内核驱动;
- 漏洞原理:多个线程在缺少锁保护的情况下同时访问或释放同一块内存,引发释放后重用(Use-After-Free)或双重释放(Double Free),可导致远程代码执行(RCE)或拒绝服务(DoS);
4. 现代攻击技术
过去,由于网络延迟(Jitter)的存在,攻击者利用并发漏洞往往需要“碰运气”。但在现代网络环境下,攻击技术已经高度精准化。
4.1 HTTP/2 单数据包攻击
这是目前利用 Web 竞争条件漏洞最顶尖的技术(由 PortSwigger 安全研究员于近年提出):
- 传统 HTTP/1.1:每个请求都是独立的 TCP 包,受网络波动影响,到达服务器有先后顺序;
- HTTP/2 多路复用:允许在同一个 TCP 连接上并发发送多个请求;
- 攻击者可以组装 50 个请求,并在每个请求的末尾扣留最后一个字节(Last-byte)。最后,将所有请求的最后一个字节封装进同一个 TCP 数据包中发出;
- 威力:服务器的网卡在接收到这个单数据包的瞬间,会同时解包并同时触发 50 个线程。网络延迟被彻底降低为 0,漏洞利用成功率接近 100%;
4.2 分布式放大效应
在微服务架构中,一个请求可能需要经过 网关 -> 用户微服务 -> 钱包微服务 -> 数据库(读写分离)。
由于微服务之间存在 RPC 调用耗时,且数据库主从同步存在毫秒级延迟,这导致“漏洞窗口”被人为放大了几十甚至几百倍,使得竞争条件更容易被触发。
5. 漏洞实例
以下是提供的两个最经典的实例:一个是现代 Web 业务中最常见的“钱包超提(薅羊毛)”代码实例;另一个是底层操作系统中经典的“文件写入提权(TOCTOU)”实例。
5.1 Web 业务中的钱包余额“超提”漏洞
这是典型的金融/业务逻辑型竞争条件。开发者在写代码时,通常会按照“先检查、后扣款”的线性思维编写,但在高并发下,这种逻辑会瞬间崩溃。
5.1.1 存在漏洞的后端代码
@app.route('/api/withdraw', methods=['POST'])
def withdraw():
user_id = session.get('user_id')
amount = float(request.json.get('amount')) # 假设用户请求提现 100 元
# 【1. TOC - 检查阶段】查询用户当前余额
user = db.query("SELECT balance FROM users WHERE id = %s", (user_id,))
current_balance = user['balance']
if current_balance >= amount:
# 模拟网络延迟或微服务调用,拉大“时间窗口”
time.sleep(0.05)
# 【2. TOU - 使用阶段】执行扣款并转账
new_balance = current_balance - amount
db.execute("UPDATE users SET balance = %s WHERE id = %s", (new_balance, user_id))
# 触发外部支付网关转账给用户
payment_gateway.transfer(user_id, amount)
return jsonify({"status": "success", "msg": "提现成功"})
else:
return jsonify({"status": "failed", "msg": "余额不足"})
5.1.2 攻击路径(HTTP/2 单数据包攻击)
假设攻击者的账户里只有 100 元余额:
- 1. 攻击者使用工具(如 Burp Suite 的 Turbo Intruder 模块),组装10个完全相同的提现100元的请求;
- 2. 利用 HTTP/2 协议,将这 10 个请求封装在同一个 TCP 数据包中发送给服务器;
- 3. 服务器的并发线程同时处理这 10 个请求:
- 线程 1 到 10:在同一毫秒内,全部执行完
SELECT balance。由于此时大家都还没执行到 UPDATE 语句,所有线程查到的余额都是 100 元; - 线程 1 到 10:全部通过了
if current_balance >= amount的校验; - 结果:10 个线程全部成功执行了扣款和转账,黑客成功提现了 1000 元,而数据库中的余额最终被扣成了 -900 元 或被最后一个线程覆盖为 0 元;
- 线程 1 到 10:在同一毫秒内,全部执行完
5.1.3 安全修复方案(原子化一元操作)
消除时间窗口,直接将“检查”与“扣款”合并为一条无法分割的原子化 SQL 语句,利用数据库自带的行锁机制来防御:
@app.route('/api/withdraw', methods=['POST'])
def withdraw_fixed():
user_id = session.get('user_id')
amount = float(request.json.get('amount'))
# 将“检查余额是否充足”直接写入 UPDATE 语句的 WHERE 条件中
# 数据库执行这条语句时会自动对该行加锁(排他锁)
result = db.execute(
"UPDATE users SET balance = balance - %s WHERE id = %s AND balance >= %s",
(amount, user_id, amount)
)
# db.execute 通常会返回受影响的行数 (Affected Rows)
if result.affected_rows == 1:
# 只有真正扣款成功的那一次请求,才会触发转账
payment_gateway.transfer(user_id, amount)
return jsonify({"status": "success", "msg": "提现成功"})
else:
# 其他并发请求在执行 SQL 时,由于 balance 已经被第一笔请求扣减,不再满足 "balance >= amount",会返回 0 行受影响
return jsonify({"status": "failed", "msg": "并发请求失败或余额不足"})
5.2 底层系统中的“文件写入越权/提权”漏洞
这是 C/C++ 或是系统运维脚本中非常经典的 TOCTOU(Time-of-Check to Time-of-Use) 漏洞。通常发生在具有高权限(如 root)的后台服务处理用户临时文件时。
5.2.1 存在漏洞的 C 语言系统代码
这段代码的作用是:检查某个日志文件是否属于当前普通用户,如果是,就以 root 权限向该日志文件追加写入系统运行状态。
#include <unistd.h>
#include <fcntl.h>
void write_user_log(char *filename, char *log_data) {
// 【1. TOC - 检查阶段】
// access() 函数检查当前启动程序的用户对 filename 是否拥有写权限 (W_OK)
if (access(filename, W_OK) == 0) {
// ---- 漏洞时间窗口 ----
// 攻击者在这两行代码执行的微秒级间隙内进行恶意操作
// --------------------
// 【2. TOU - 使用阶段】
// 如果检查通过,程序(以 root 权限)打开文件并写入数据
int fd = open(filename, O_WRONLY | O_APPEND);
write(fd, log_data, strlen(log_data));
close(fd);
}
}
5.2.2 攻击路径(符号链接劫持)
假设该高权限程序会定期去读写普通用户目录下的 /home/user/my_log.txt:
- 攻击者编写一个监控脚本,疯狂监控该程序的运行状态;
- 当程序运行到
access("/home/user/my_log.txt", W_OK)时,由于这个文件确实是普通用户创建的,access检查返回 0(允许通过); - 在
open还没开始执行的极短间隙内,攻击者迅速在底层执行删除并创建软链接的操作:
rm /home/user/my_log.txt
ln -s /etc/passwd /home/user/my_log.txt
- 紧接着,程序执行
open("/home/user/my_log.txt", ...)。由于此时my_log.txt已经指向了系统核心文件/etc/passwd,程序直接越权打开了/etc/passwd并追加了数据; - 结果:攻击者通过这种方式向
/etc/passwd中写入了一个自定义的、拥有 root 权限的无密码新用户,成功实现本地提权;
5.3 安全修复方案(使用原子化文件操作标志)
在底层操作系统编程中,修复此类漏洞的核心是不使用分开的 access 和 open,而是直接使用 open 的原子化标志,或在打开文件后通过文件描述符(File Descriptor)进行状态校验。
#include <fcntl.h>
void write_user_log_fixed(char *filename, char *log_data) {
// 放弃使用 access() 检查路径
// 直接使用 open 并在权限不足时由系统底层直接拒绝
// 并在打开文件时加入 O_NOFOLLOW 标志:如果 filename 是一个符号链接,open 会直接报错失败
int fd = open(filename, O_WRONLY | O_APPEND | O_NOFOLLOW);
if (fd >= 0) {
// 进一步校验:打开的文件的实际拥有者是否为当前用户,防止其他形式的欺骗
struct stat st;
fstat(fd, &st);
if (st.st_uid == getuid()) {
write(fd, log_data, strlen(log_data));
}
close(fd);
}
}
总结:不论是 Web 钱包还是操作系统文件,攻击者的核心思路永远是:在系统说“可以操作”之后、到系统“完成操作”之前,迅速改变被操作对象的状态。
6. 漏洞现状与防御
修复竞争条件绝对不能靠提高代码执行速度来缩短时间窗口,而必须消除时间窗口,关键在于破坏时间窗口,确保敏感操作的原子性和隔离性。即消除竞争条件的唯一核心思想是:让“检查”和“使用”合并为一个不可分割的整体——即“原子操作(Atomic Operation)”。
6.1 数据库层:原子化一元操作与锁机制(最直接、最高效)
- 原子化一元操作:绝对不要先 Select 出来在代码里做减法,再 Update 回去。直接利用数据库的行锁特性:
UPDATE users SET points = points - 10 WHERE id = 1 AND points >= 10;通过检查数据库返回的 影响行数 (Affected Rows) 是否为 1 来判断操作是否真正成功; - 悲观锁:在查询时直接锁定记录。例如使用
SELECT ... FOR UPDATE。在当前事务提交前,其他任何线程无法读取或修改这行数据,都必须排队等待,强制并发变成串行; - 乐观锁:在表中增加 version(版本号)字段,更新时比对版本号,若版本已被其他线程改变,则当前事务失败:
-- 线程 A 和 B 都查到 version = 1
-- 线程 A 先更新成功:
UPDATE accounts SET balance = balance - 10, version = version + 1 WHERE id = 1 AND version = 1;
-- 此时数据库中 version 变为了 2。线程 B 再执行该语句时,由于 version = 1 条件不匹配,更新失败。
6.2 应用层与分布式架构
- 分布式锁 (Distributed Lock):在微服务架构下,单机锁如 Java 的
synchronized、Go 的sync.Mutex)无法跨服务器生效。必须使用基于 Redis(Redlock 算法)或 ZooKeeper 的分布式锁,在整个集群的入口处将相同用户的并发请求锁住; - 消息队列串行化:令牌桶/队列化处理:对于高并发的秒杀、提现业务,将请求塞入消息队列(如 RabbitMQ/Kafka)进行串行化消费,从根本上杜绝并发;
6.3 文件系统层
- 避免使用可预测的路径:使用安全的文件 API(如 Python 的
tempfile.mkstemp()),它在创建文件时会自动生成随机名并带上O_EXCL标志; - 原子化标志打开:在底层
open文件时,同时传入O_CREAT | O_EXCL标志。如果文件已经存在(被攻击者用符号链接占位了),则立即报错拒绝执行,同时结合O_NOFOLLOW阻止程序跟随符号链接,防止劫持;
1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。