抽奖与红包活动逻辑漏洞
"恭喜中奖 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 锁 |
| 时间校验 | 后端必须校验活动开始/结束时间 |
| 幂等性 | 发奖接口支持幂等,回调可能重放 |
八、总结
抽奖系统的核心安全原则:前端是纯粹的展示层,所有决策逻辑(概率、次数、金额、库存)都在后端完成。
攻击面主要集中在:
- 信任前端数据(概率、次数、金额)→ 必须后端计算
- 检查-执行分离(次数检查后并发扣)→ Redis 原子操作
- 状态不一致(库存、次数、发奖)→ 数据库事务 + 行级锁
抽奖漏洞的损失通常是瞬时爆炸式的,一个脚本就能刷空整场活动的奖池。设计时宁可保守(降低概率、限死次数、强制实名),也不要让系统处于可被脚本批量攻击的状态。