抽奖与红包活动逻辑漏洞

"恭喜中奖 iPhone 15 Pro!"——你以为是运气,其实可能是服务器端的概率操控;或者反过来,你以为能无限抽,其实是前端把"已抽完"藏起来了。

一、抽奖系统的核心组件

┌────────────┐    ┌────────────┐    ┌────────────┐    ┌────────────┐
│ 抽奖请求    │───▶│ 资格校验    │───▶│ 概率计算    │───▶│ 发奖扣库存  │
└────────────┘    └────────────┘    └────────────┘    └────────────┘
                        │                   │                   │
                   每日次数            随机算法             事务+锁
                   用户余额            奖池配置             幂等校验

二、漏洞一:前端概率作弊

2.1 漏洞描述

抽奖概率写在前端 JS 里,攻击者修改 JS 让自己 100% 中奖。

2.2 漏洞代码

<!-- VULNERABLE: 概率写在前端 -->
<script>
const PRIZES = [
  { id: 1, name: 'iPhone 15', probability: 0.01 },  // 1%
  { id: 2, name: '100元红包', probability: 0.1 },
  { id: 3, name: '10元红包', probability: 0.3 },
  { id: 4, name: '谢谢参与', probability: 0.59 },
];

function drawPrize() {
  const rand = Math.random();
  let cumulative = 0;
  for (const prize of PRIZES) {
    cumulative += prize.probability;
    if (rand < cumulative) {
      return prize;
    }
  }
}

document.getElementById('drawBtn').addEventListener('click', () => {
  const prize = drawPrize();
  alert(`恭喜获得: ${prize.name}`); // ❌ 直接在前端决定
});
</script>

2.3 攻击

攻击者把 probability: 1.0 全部改成 iPhone 的概率,或者直接 Hook Math.random()。中奖后如果后端也发奖,那就直接白嫖。

2.4 修复

所有概率计算必须在服务端完成。

// FIXED: 后端决定结果,前端只负责展示
app.post('/api/lottery/draw', authMiddleware, async (req, res) => {
  const userId = req.user.id;
  const activityId = req.body.activityId;

  // 1. 资格校验
  const eligible = await checkEligibility(userId, activityId);
  if (!eligible.ok) return res.status(400).json({ error: eligible.reason });

  // 2. 服务端概率计算
  const prize = await calculatePrize(activityId);

  // 3. 发奖
  if (prize) {
    await grantPrize(userId, prize);
  }

  // 4. 扣减抽奖次数
  await decrementDrawCount(userId, activityId);

  // 5. 返回结果
  res.json({ success: true, prize: prize?.name || '谢谢参与' });
});

async function calculatePrize(activityId) {
  const activity = await db.Activity.findOne({ where: { id: activityId, status: 'ACTIVE' } });
  if (!activity) return null;

  const prizes = await db.Prize.findAll({ where: { activityId, stock: { $gt: 0 } } });
  if (prizes.length === 0) return null;

  // 加权随机(服务端)
  const totalWeight = prizes.reduce((s, p) => s + p.weight, 0);
  let rand = crypto.randomInt(0, totalWeight); // ✅ 用密码学安全的随机数

  for (const prize of prizes) {
    rand -= prize.weight;
    if (rand <= 0) {
      // 扣减库存(原子操作)
      const result = await db.Prize.decrement('stock', {
        by: 1, where: { id: prize.id, stock: { $gt: 0 } },
      });
      if (result[0] > 0) return prize; // ✅ 扣减成功才返回
      // 库存已被别人扣完,继续下一个
    }
  }
  return null; // 没中
}

三、漏洞二:并发绕过次数限制

3.1 漏洞描述

用户的抽奖次数限制是 "每天 3 次",但并发请求可以同时通过检查,实际抽几十次。

3.2 漏洞代码

// VULNERABLE: 次数检查和扣减分离
app.post('/api/lottery/draw', authMiddleware, async (req, res) => {
  const userId = req.user.id;

  const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
  const drawCount = await db.DrawLog.count({ where: { userId, createdAt: { $gte: todayStart } } });

  if (drawCount >= 3) {
    return res.status(400).json({ error: '今日次数已用完' });
  }

  // ❌ 多个请求同时通过上面检查,这里并发执行
  const prize = await calculatePrize(req.body.activityId);
  await db.DrawLog.create({ userId, prizeId: prize?.id });

  res.json({ prize });
});

3.3 攻击

# 同时发 20 个抽奖请求
seq 1 20 | xargs -P 20 -I{} curl -s -X POST   -H "Authorization: Bearer TOKEN"   -d '{"activityId":1}'   http://target/api/lottery/draw
# 结果:实际抽了 15 次!

3.4 修复:Redis DECR 原子扣减

// FIXED: Redis 原子计数 + 分布式锁
app.post('/api/lottery/draw', authMiddleware, async (req, res) => {
  const userId = req.user.id;
  const { activityId } = req.body;

  // 1. 活动状态检查
  const activity = await db.Activity.findOne({ where: { id: activityId, status: 'ACTIVE' } });
  if (!activity) return res.status(400).json({ error: '活动不存在或已结束' });

  // 2. Redis 原子扣减次数
  const countKey = `lottery:count:${activityId}:${userId}:${todayKey()}`;
  const remaining = await redis.decr(countKey);
  if (remaining === -1) {
    // 第一次设置,先设为 maxCount - 1
    await redis.set(countKey, activity.maxPerUser - 1, 'EX', 86400);
  }
  if (remaining < 0) {
    await redis.incr(countKey); // 还回去
    return res.status(400).json({ error: '今日次数已用完' });
  }

  // 3. 分布式锁防止奖品竞态
  const lockKey = `lottery:lock:${activityId}:${userId}`;
  const locked = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!locked) {
    await redis.incr(countKey);
    return res.status(429).json({ error: '操作太频繁' });
  }

  try {
    // 4. 事务内抽奖
    const result = await db.sequelize.transaction(async (t) => {
      const prize = await drawPrizeTransaction(activityId, t);
      if (prize) {
        await db.PrizeLog.create({
          userId, prizeId: prize.id, activityId,
        }, { transaction: t });
      }
      return prize;
    });

    res.json({ success: true, prize: result?.name || '谢谢参与' });
  } finally {
    await redis.del(lockKey);
  }
});

function todayKey() {
  const d = new Date();
  return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
}

四、漏洞三:红包金额客户端可控

4.1 漏洞描述

抢红包接口允许用户自己指定金额,导致可以给自己发任意金额。

4.2 漏洞代码

// VULNERABLE: 红包金额由用户控制
app.post('/api/redpacket/open', authMiddleware, async (req, res) => {
  const { redpacketId, amount } = req.body; // ❌ 用户自己传 amount?

  const rp = await db.RedPacket.findOne({ where: { id: redpacketId } });
  if (!rp) return res.status(404).json({ error: '红包不存在' });
  if (rp.expireAt < Date.now()) return res.status(400).json({ error: '红包已过期' });

  // ❌ 不校验 amount 的合法性
  await db.Wallet.increment('balance', { by: amount, where: { id: req.user.id } });
  await db.RedPacket.decrement('totalAmount', { by: amount, where: { id: redpacketId } });

  res.json({ opened: true, amount });
});

4.3 攻击

# 抢红包时直接把 amount 改成 999999
curl -X POST http://target/api/redpacket/open   -H "Authorization: Bearer TOKEN"   -d '{"redpacketId":123, "amount":999999}'

4.4 修复:后端算法决定金额

// FIXED: 后端用二倍均值法决定金额
app.post('/api/redpacket/open', authMiddleware, async (req, res) => {
  const { redpacketId } = req.body;

  // Redis 分布式锁(防并发)
  const lockKey = `rp:open:${redpacketId}:${req.user.id}`;
  const locked = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!locked) return res.status(429).json({ error: '重复请求' });

  try {
    const result = await db.sequelize.transaction(async (t) => {
      const rp = await db.RedPacket.findOne({
        where: { id: redpacketId, status: 'ACTIVE' },
        transaction: t,
        lock: t.LOCK.UPDATE,
      });

      if (!rp) throw new Error('红包不存在');
      if (rp.expireAt < Date.now()) throw new Error('红包已过期');

      // 检查是否已抢过
      const opened = await db.RedPacketOpen.findOne({
        where: { redpacketId, userId: req.user.id },
        transaction: t,
      });
      if (opened) throw new Error('已抢过');

      // 二倍均值法:剩余金额 / 剩余人数 × 2
      const remainAmount = rp.totalAmount - rp.openedAmount;
      const remainCount = rp.totalCount - rp.openedCount;

      if (remainAmount <= 0 || remainCount <= 0) {
        throw new Error('红包已被抢完');
      }

      const maxAmount = remainAmount / remainCount * 2;
      const minAmount = 0.01;

      // 最后一个人拿走全部剩余
      const amount = remainCount === 1
        ? Math.round(remainAmount * 100) / 100
        : Math.round(crypto.randomFloat(minAmount, Math.min(maxAmount, remainAmount - (remainCount - 1) * 0.01)) * 100) / 100;

      // 更新红包状态
      await rp.update({
        openedAmount: db.sequelize.literal(`opened_amount + ${amount}`),
        openedCount: db.sequelize.literal('opened_count + 1'),
        status: remainCount === 1 ? 'FINISHED' : rp.status,
      }, { transaction: t });

      // 增加用户余额
      await db.Wallet.increment('balance', {
        by: amount, where: { id: req.user.id }, transaction: t,
      });

      // 记录
      await db.RedPacketOpen.create({
        redpacketId, userId: req.user.id, amount,
      }, { transaction: t });

      return amount;
    });

    res.json({ success: true, amount: result });
  } catch (err) {
    res.status(400).json({ error: err.message });
  } finally {
    await redis.del(lockKey);
  }
});

五、漏洞四:前端藏"已中奖"

5.1 漏洞描述

某些抽奖页面在抽奖后,如果中奖了,前端 JS 会直接弹窗展示;但如果没中奖,JS 把"谢谢参与"的结果隐藏掉,让你误以为还没抽。

5.2 另一个变种

后端把大奖的 stock 返回给前端,前端发现 stock > 0 就一直让你"再抽一次",但后端概率算法根本不会让你中。

5.3 修复

  • 后端返回的奖品信息中 不携带库存/概率数据
  • 前端只负责展示最终结果,不持有任何概率逻辑
  • 抽奖按钮每次点击必须请求后端,结果以服务端为准

六、漏洞五:活动时间窗口绕过

6.1 漏洞描述

活动还没开始或已经结束,但前端按钮没禁用,用户点击后后端也没校验时间。

6.2 修复

// 统一时间校验中间件
function checkActivityTime(req, res, next) {
  const { activityId } = req.body;
  const now = Date.now();

  db.Activity.findOne({ where: { id: activityId } }).then(activity => {
    if (!activity) return res.status(404).json({ error: '活动不存在' });
    if (now < activity.startTime) {
      return res.status(400).json({ error: '活动未开始' });
    }
    if (now > activity.endTime) {
      return res.status(400).json({ error: '活动已结束' });
    }
    next();
  });
}

七、抽奖系统安全清单

检查项 说明
概率位置 必须在服务端,前端不持有任何概率逻辑
随机算法 crypto.randomInt 而非 Math.random()
次数限制 Redis DECR 原子扣减,不做"查-改-存"
奖池扣减 行级锁 + 事务,确保 stock >= 1 才能扣
金额计算 后端算法决定(二倍均值/固定值),不让用户传
重复抽奖 唯一索引(user_id + activity_id)+ Redis 锁
时间校验 后端必须校验活动开始/结束时间
幂等性 发奖接口支持幂等,回调可能重放

八、总结

抽奖系统的核心安全原则:前端是纯粹的展示层,所有决策逻辑(概率、次数、金额、库存)都在后端完成。

攻击面主要集中在:

  1. 信任前端数据(概率、次数、金额)→ 必须后端计算
  2. 检查-执行分离(次数检查后并发扣)→ Redis 原子操作
  3. 状态不一致(库存、次数、发奖)→ 数据库事务 + 行级锁

抽奖漏洞的损失通常是瞬时爆炸式的,一个脚本就能刷空整场活动的奖池。设计时宁可保守(降低概率、限死次数、强制实名),也不要让系统处于可被脚本批量攻击的状态。