适用场景
竞态条件漏洞防御要消除的是“判断状态”与“依据该判断执行操作”之间的时间窗口。竞态条件(Race Condition)与其典型形态 TOCTOU(Time-of-Check to Time-of-Use)指的正是这一间隙:攻击者用并发请求在窗口内改变状态,使校验逻辑失效。它不像 SQL 注入那样在单次请求里就能看到效果,因此常被代码审计遗漏,却在电商、票务、积分、文件上传这类高并发业务中屡屡造成真实损失。
以下场景是本类漏洞的高发区:文件上传后先校验后缀再落盘,被并发替换为可执行文件;优惠券、秒杀库存、抽奖次数“先查余额再扣减”,导致超卖与重复领取;多实例部署下使用本地锁,实例之间互不可见;运维脚本先判断文件是否存在再创建,被符号链接指向敏感路径。
前置条件
- 应用需要经过并发测试,具备可复现的压测手段:
ab、wrk、hey或 Python 的ThreadPoolExecutor。 - 数据库支持行级锁与原子更新,MySQL 5.7+ / PostgreSQL 9.5+ 即可满足本文示例。
- 若使用分布式锁,需要 Redis 5.0 及以上(支持
SET key value NX PX与 Lua 脚本)。 - Linux 内核 3.6 以上(支持
fs.protected_symlinks与fs.protected_hardlinks)。 - 能够修改应用代码与数据库表结构,并具备灰度发布能力——竞态修复常涉及业务流程调整,全量上线风险较高。
原理说明
竞态漏洞的成立需要三个条件同时具备:存在可被并发访问的共享状态;检查与修改之间存在非原子的间隙;攻击者能在窗口期内触发第二次操作。修复思路对应三条:消除共享状态、把检查与修改合并为原子操作、在窗口外增加互斥。
按层次划分,实战中遇到的竞态主要有四类。
文件系统层 TOCTOU。典型案例是上传流程写成“保存临时文件 → 校验 MIME 与后缀 → 移动到 Web 目录”。攻击者在“校验通过”和“移动”之间发起另一请求覆盖临时文件,或在“判断目标路径不存在”与“创建”之间插入符号链接,使写入落到 /etc/cron.d/ 等敏感位置。核心问题是路径与文件状态被多次解析,每次解析结果可能不同。
业务逻辑层竞态。库存扣减写成 SELECT qty FROM stock WHERE id=1 后应用层判断 qty > 0,再 UPDATE stock SET qty = qty - 1。两个并发请求可能同时读到 qty = 1,各自判断通过,最终库存变成 -1,或同一张券被领两次。核心问题是判断依据来自一次可能已过期的读取。
分布式层竞态。单机用 synchronized、flock、进程内互斥量都能解决,但服务扩容到多实例后锁的可见性消失,等价于没有锁。核心问题是锁的作用域小于临界资源的访问范围。
网络与重试层竞态。客户端超时重发、网关重试、用户连点,都会对同一逻辑操作产生多次请求。核心问题是缺少请求级去重标识,即使加锁也只会变成串行执行多次。
这四类对应到下文步骤:内核与系统调用层的原子化、数据库层的原子更新、分布式层的互斥锁、请求层的幂等键。
操作步骤
步骤 1:用 O_EXCL 与原子改名消除文件竞态
Python 的正确写法是“一次创建即独占”,而不是“先判断再创建”:
import os, tempfile, shutil, pathlib
UPLOAD_DIR = pathlib.Path("/data/uploads")
def save_upload(stream, original_name: str) -> str:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# 1) 在目标目录内用唯一名原子创建,O_EXCL 保证不覆盖已有文件
fd, tmp_path = tempfile.mkstemp(prefix=".up_", dir=str(UPLOAD_DIR))
try:
with os.fdopen(fd, "wb") as f:
shutil.copyfileobj(stream, f, length=1024 * 1024)
# 2) 校验只针对本次已落盘的 fd,路径不再重新解析
if os.path.getsize(tmp_path) > 20 * 1024 * 1024:
raise ValueError("文件过大")
with open(tmp_path, "rb") as f:
head = f.read(8)
if not head.startswith(b"\x89PNG\r\n\x1a\n"): # 白名单魔数校验
raise ValueError("文件类型不允许")
# 3) 用不可预测名 + 原子 rename 发布,避免可执行后缀与竞态覆盖
final = UPLOAD_DIR / (os.urandom(8).hex() + ".png")
os.chmod(tmp_path, 0o640)
os.replace(tmp_path, final) # 同分区内原子操作
return final.name
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
关键点是三条:mkstemp 在目标目录内创建并返回 fd,后续校验与写入都基于该 fd,不存在“路径被替换”的窗口;os.replace 是原子操作,不会出现半成品文件被访问;最终文件名由随机数生成,攻击者无法预测路径做指向替换。
上传目录还必须在服务器侧禁止脚本执行,这一层与竞态修复互补:
location ^~ /uploads/ {
root /data;
location ~* \.(php|php5|phtml|jsp|asp|aspx|sh|py|cgi|pl)$ {
return 403;
}
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "attachment" always;
}
步骤 2:解压场景必须避免“先解压再校验”
归档解压是竞态的另一高发点。错误做法是解压到临时目录后再扫描非法路径,此时文件已落地,扫描与清理之间仍有窗口。正确做法是先遍历归档条目清单做白名单判断,再决定是否解压:
import zipfile, pathlib
ALLOW = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
def safe_extract(zip_path: str, dest: str) -> list:
dest_root = pathlib.Path(dest).resolve()
written = []
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
name = info.filename
if ".." in name or name.startswith("/") or "\\" in name:
raise ValueError("非法路径: %s" % name) # 先拒绝,绝不解压
member = pathlib.Path(name)
if member.is_symlink() or (info.external_attr >> 16) & 0o170000 == 0o120000:
raise ValueError("拒绝符号链接条目")
target = (dest_root / name).resolve()
if not str(target).startswith(str(dest_root) + os.sep):
raise ValueError("越出目标目录: %s" % name)
if target.suffix.lower() not in ALLOW:
raise ValueError("后缀不允许: %s" % name)
written.append(name)
# 清单全部通过后才真正写盘
zf.extractall(path=dest_root)
return written
(info.external_attr >> 16) & 0o170000 == 0o120000 是判断 zip 条目是否为符号链接的标准方式。归档中携带符号链接条目本身就是攻击信号,直接拒绝比尝试安全解压更可靠。
步骤 3:打开内核符号链接保护
系统层 TOCTOU 多依赖符号链接与硬链接跟随,Linux 内核提供现成开关:
# 立即生效
sysctl -w fs.protected_symlinks=1
sysctl -w fs.protected_hardlinks=1
sysctl -w fs.protected_fifos=2
sysctl -w fs.protected_regular=2
# 持久化
cat >> /etc/sysctl.d/99-race-hardening.conf <<'EOF'
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
fs.protected_fifos = 2
fs.protected_regular = 2
kernel.yama.ptrace_scope = 1
EOF
sysctl --system
fs.protected_symlinks=1 使全局可写目录(如 /tmp)中,非属主无法通过自建符号链接让高权限进程写入任意位置,直接阻断最经典的 /tmp 竞态提权链。fs.protected_regular=2 则挡住 /tmp 下 FIFO 与普通文件的写入竞态。副作用评估见 FAQ 第三问。
步骤 4:用原子 UPDATE 消除库存与余额竞态
把“读—判断—写”压缩成一条带条件的 UPDATE,让数据库在行锁内完成判断与修改。表结构先补约束:
-- 库存不能为负,唯一索引兜底防重
ALTER TABLE stock
ADD CONSTRAINT chk_stock_qty CHECK (qty >= 0);
CREATE TABLE coupon_claim (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
coupon_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_coupon_user (coupon_id, user_id)
) ENGINE=InnoDB;
扣减语句改成单条原子操作,并依据受影响行数判断成败:
UPDATE stock
SET qty = qty - 1
WHERE sku_id = 1001
AND qty >= 1;
-- 应用层判断 affected_rows == 1 才算成功,== 0 直接返回“库存不足”
领券则依赖唯一索引,让数据库替你去重:
INSERT INTO coupon_claim (coupon_id, user_id) VALUES (5001, 88231);
-- 重复领取时抛出 1062 Duplicate entry,应用层捕获后返回“已领取”
当业务必须在事务内先查后写时,把普通读改成加锁读:SELECT … FOR UPDATE 会持有行锁直到事务提交,其他并发事务被阻塞而非读到脏状态。使用前确认隔离级别为 REPEATABLE READ 或更高,并控制事务长度,避免行锁升级为表锁。
步骤 5:多实例场景使用分布式锁
单机锁在多实例下失效,必须引入共享存储层的互斥。Redis 的正确用法是“SET NX PX 加值校验”三步合一,并用 Lua 保证释放时只删自己的锁:
import redis, uuid, time
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def settle(order_id: int, token: str, ttl_ms: int = 5000) -> bool:
key = "lock:settle:%d" % order_id
val = uuid.uuid4().hex # 唯一标识本次持锁者
if not r.set(key, val, nx=True, px=ttl_ms): # 原子加锁,带自动过期
return False
try:
return do_settlement(order_id) # 临界区:查余额 + 写入流水
finally:
r.eval(RELEASE_LUA, 1, key, val) # 只释放自己的锁
两点必须注意:nx=True, px=ttl_ms 必须与 set 同时下发,分成先 SETNX 再 EXPIRE 会产生“加锁成功但进程崩溃导致死锁”的竞态;释放锁必须比对 value,否则可能删掉别人的锁。临界区 TTL 到期后锁自动释放,业务侧要能接受重复执行,因此仍需步骤 6 的幂等保护。
步骤 6:请求级幂等,堵住重试与连点
幂等键是最后一道防线。要求客户端在创建订单、发起支付这类非幂等操作的请求头中携带唯一标识:
POST /api/orders HTTP/1.1
Host: api.example.com
Idempotency-Key: 7f3c1e2a-9b44-4d18-8a20-2f6b0c5d9e11
Content-Type: application/json
服务端先用该 key 做一次 INSERT ... ON DUPLICATE KEY UPDATE 抢占,成功者执行业务,失败者返回首次结果。表结构:
CREATE TABLE idempotency_key (
idem_key VARCHAR(80) PRIMARY KEY,
request_hash CHAR(64) NOT NULL,
response TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
request_hash 用于校验同一个 key 是否被用于不同请求体:哈希不一致应返回 422 而非复用旧结果,否则会出现“复用 key 篡改金额”的新问题。表按 created_at 定期清理,保留 7 天即可。
步骤 7:运维脚本用 flock 互斥
备份、日志切割、发布部署这类脚本并发执行会互相破坏,用内核锁替代“检查 pid 文件”:
#!/bin/bash
set -euo pipefail
LOCK=/var/lock/$(basename "$0").lock
exec 9>"$LOCK"
flock -n 9 || { echo "已有实例在运行,退出" >&2; exit 1; }
rsync -a --delete /data/uploads/ /backup/uploads/
flock -n 9 非阻塞获取,拿不到锁立刻退出;锁由内核维护,进程异常退出时自动释放,不会残留 stale lock。
配置验证
第一步,验证库存扣减没有超卖:
# 用 hey 并发 200 个请求,模拟 200 个用户同时抢 1 件库存
hey -n 200 -c 200 -m POST -H "Content-Type: application/json" \
-d '{"sku_id":1001,"qty":1}' https://api.example.com/api/orders
# 统计成功数(业务返回码约定 0 为成功)
# 结果只应有 1 个成功,其余为“库存不足”
压测结束后核对数据库:SELECT qty FROM stock WHERE sku_id = 1001; 应为 0,绝不能为负数。若 chk_stock_qty 约束被触发,说明仍有代码路径绕过原子 UPDATE。
第二步,验证领券幂等。用同一 user_id 并发请求 50 次:
seq 50 | xargs -P 50 -I{} curl -s -X POST https://api.example.com/api/coupon/claim \
-H "Authorization: Bearer $TOKEN" -d '{"coupon_id":5001}' | sort | uniq -c
结果应是「成功 1 次 + 已领取 49 次」。随后做一致性校验:
SELECT coupon_id, user_id, COUNT(*) AS c
FROM coupon_claim
GROUP BY coupon_id, user_id
HAVING c > 1;
返回空结果即符合预期。该查询可扩展为对所有业务表的周期性巡检,作为竞态故障的回归手段。
第三步,验证上传目录不产生可执行文件:
find /data/uploads -type f -name '*.php' -o -name '*.phtml' -o -name '*.jsp'
find /data/uploads -type l # 应无符号链接
find /data/uploads -perm -o+x -type f # 应无可执行权限文件
三条命令都返回空即合格。若第二条有输出,说明符号链接条目未被拒绝,需回到步骤 2 检查 ZIP 条目类型判断。
第四步,确认内核保护已生效:
sysctl fs.protected_symlinks fs.protected_hardlinks fs.protected_regular fs.protected_fifos
四项应分别输出 1、1、2、2。
常见问题
Q1:已经用了事务,为什么还会超卖
事务只保证原子性与一致性,不保证把并发请求串行化。默认隔离级别下,两个事务可同时读到 qty = 1,各自计算 1 - 1 = 0 并写回,后提交的覆盖前一个,最终库存为 0 但实际卖出 2 件。这属于典型的丢失更新。解决办法两条:把判断条件写进 UPDATE 自身(WHERE qty >= 1 并检查 affected_rows),或对读取加 FOR UPDATE 使并发事务排队。最优选前者,不占用锁等待时间,吞吐更高。
Q2:Redis 分布式锁在集群故障切换时会丢锁,怎么办
主从异步复制下,主节点写入锁后未同步就宕机,从节点升主后锁丢失,另一客户端可再次获取,形成双持锁。务实做法是三重兜底:数据库层加唯一索引或 CHECK 约束,让绕过锁的重复写入无法落库;业务侧保证幂等,即使双持锁,第二次执行也会被幂等键拦下;缩短临界区,把非必要 IO 移出锁外。数据库约束才是最终防线,锁只是性能优化手段。
Q3:开启 fs.protected_symlinks 后业务报权限错误
先确认报错路径。若集中在 /tmp、/dev/shm 等全局可写目录,多半是应用在用固定名文件做临时交换。正确改法是改用 mkstemp 一类的随机名接口,而不是放宽内核开关。若某个老旧组件无法修改,可为其单独设置工作目录(如 /var/lib/app/tmp,属主为应用用户、权限 700),把该目录排除在全局可写范围之外,既保住内核保护又不影响业务。
Q4:幂等键应该由谁生成,有效多久
由客户端生成,用 UUIDv4 或业务流水号,要求全局唯一且不可预测。界面上应在打开表单时生成并固定,而不是每次点击重新生成,否则连点会产生多个 key,幂等直接失效。有效期按业务容忍度设定:支付类 24 小时,下单类 7 天,任务重试 1 小时。
总结
- 竞态漏洞的成立条件是“共享状态 + 非原子间隙 + 可并发触发”,修复路径有三条:消除共享状态、把判断与修改合并为原子操作、在窗口外加互斥。
- 文件与归档场景优先使用系统调用的原子语义:O_EXCL、mkstemp、os.replace,并把路径校验建立在文件描述符而非字符串路径上。
- 业务扣减场景把判断条件写进 SQL 的 WHERE 子句并检查受影响行数,配合 CHECK 约束与唯一索引做数据库层兜底;多实例下用分布式锁配合幂等键。
- 内核侧打开 fs.protected_symlinks / protected_hardlinks / protected_regular 可一次性阻断大量 /tmp 相关的经典竞态链,成本低、收益高。