支付逻辑漏洞全解析
支付模块是电商系统的核心,一旦出现逻辑漏洞,攻击者可以用 0.01 元购买 iPhone,甚至让商家倒贴钱。本文从三个真实案例出发,拆解支付系统中最容易被忽视的逻辑陷阱。
一、价格篡改(Price Tampering)
1.1 漏洞描述
前端传来商品价格,后端直接信任该价格用于扣款。攻击者可以通过 Burp Suite 或浏览器开发者工具拦截请求,把价格改成 0.01 元甚至负数。
1.2 漏洞代码示例(Node.js / Express)
// VULNERABLE: 后端信任前端传来的 price 字段
app.post('/api/order/create', async (req, res) => {
const { productId, price, quantity } = req.body; // ❌ 直接从前端取 price
const product = await db.Product.findOne({ where: { id: productId } });
if (!product) return res.status(404).json({ error: '商品不存在' });
// ❌ 没有校验 req.body.price 是否等于 product.price
const totalAmount = price * quantity;
const order = await db.Order.create({
userId: req.user.id,
productId,
unitPrice: price, // 存的是前端传来的价格!
quantity,
totalAmount,
status: 'PENDING_PAYMENT',
});
res.json({ orderId: order.id, payAmount: totalAmount });
});
1.3 攻击流程
- 正常下单:选择商品 → 点击购买 → 抓包
- 拦截请求,将
price: 5999改为price: 0.01 - 提交 → 生成金额为 0.01 元的订单
- 完成支付,成功以一分钱购得商品
1.4 修复方案
// FIXED: 后端以数据库价格为准
app.post('/api/order/create', async (req, res) => {
const { productId, quantity } = req.body; // ✅ 不再接收前端 price
const product = await db.Product.findOne({
where: { id: productId, status: 'ON_SALE' },
});
if (!product) return res.status(404).json({ error: '商品不存在' });
// ✅ 以数据库价格为准
const unitPrice = product.price;
const totalAmount = unitPrice * quantity;
// ✅ 校验库存
if (product.stock < quantity) {
return res.status(400).json({ error: '库存不足' });
}
const order = await db.Order.create({
userId: req.user.id,
productId,
unitPrice, // 使用数据库中的真实价格
quantity,
totalAmount,
status: 'PENDING_PAYMENT',
});
res.json({ orderId: order.id, payAmount: totalAmount });
});
二、重复支付(Double Spending)
2.1 漏洞描述
用户点击"支付"后,在支付回调处理或扣款环节没有做幂等性校验,导致同一笔订单被多次扣款。
2.2 漏洞代码示例(支付回调)
// VULNERABLE: 支付回调没有幂等校验
app.post('/api/payment/callback', async (req, res) => {
const { orderId, transactionId, amount, status } = req.body;
if (status !== 'SUCCESS') {
return res.json({ code: 0, msg: 'success' });
}
const order = await db.Order.findOne({ where: { id: orderId } });
if (!order) return res.status(404).json({ error: '订单不存在' });
// ❌ 没有检查 order.status 是否已经是 PAID
// 攻击者可以重放回调请求,导致多次发货/多次扣款
// 扣减库存
await db.Product.decrement('stock', { by: order.quantity, where: { id: order.productId } });
// 增加用户积分
await db.User.increment('points', { by: Math.floor(order.totalAmount), where: { id: order.userId } });
// 更新订单状态
order.status = 'PAID';
order.paidAt = new Date();
await order.save();
res.json({ code: 0, msg: 'success' });
});
2.3 攻击方式
攻击者抓包支付成功回调,重复发送该请求 10 次,结果:
- 库存被扣减 10 次,产生超卖
- 用户积分被增加 10 倍
- 数据库中订单状态虽然最终是 PAID,但中间过程产生了脏数据
2.4 修复方案:三重幂等保护
// FIXED: 三重幂等保护
app.post('/api/payment/callback', async (req, res) => {
const { orderId, transactionId, amount, status, sign } = req.body;
// 第一重:验证签名(防止伪造回调)
if (!verifySign(req.body, PAYMENT_SECRET)) {
return res.status(400).json({ error: '签名无效' });
}
if (status !== 'SUCCESS') {
return res.json({ code: 0, msg: 'success' });
}
// 第二重:Redis 分布式锁 + 订单状态原子更新
const lockKey = `payment:lock:${orderId}`;
const locked = await redis.set(lockKey, '1', 'NX', 'EX', 30);
if (!locked) {
return res.json({ code: 0, msg: 'processing' }); // 正在处理中,直接返回
}
try {
const result = await db.Order.update(
{ status: 'PAID', paidAt: new Date(), transactionId },
{
where: {
id: orderId,
status: 'PENDING_PAYMENT', // ✅ 只有待支付订单才能更新
},
}
);
if (result[0] === 0) {
// ✅ 订单已经处理过了,直接返回成功
return res.json({ code: 0, msg: 'success' });
}
const order = await db.Order.findOne({ where: { id: orderId } });
// 第三重:事务内执行库存扣减和积分发放
await db.sequelize.transaction(async (t) => {
const product = await db.Product.findOne({
where: { id: order.productId },
transaction: t,
lock: t.LOCK.UPDATE, // 行级锁,防止并发
});
if (product.stock < order.quantity) {
throw new Error('库存不足');
}
await product.decrement('stock', { by: order.quantity, transaction: t });
await db.User.increment('points', {
by: Math.floor(order.totalAmount),
where: { id: order.userId },
transaction: t,
});
});
res.json({ code: 0, msg: 'success' });
} catch (err) {
console.error('Payment callback error:', err);
res.status(500).json({ error: '处理失败' });
} finally {
await redis.del(lockKey);
}
});
三、负金额攻击(Negative Amount)
3.1 漏洞描述
用户在充值/转账/提现接口中传入负数金额,导致余额增加而非减少。
3.2 漏洞代码示例
// VULNERABLE: 没有金额正负校验
app.post('/api/wallet/transfer', async (req, res) => {
const { toUserId, amount } = req.body;
// ❌ 没有检查 amount 是否为正数
const sender = await db.User.findOne({ where: { id: req.user.id } });
if (sender.balance < amount) {
return res.status(400).json({ error: '余额不足' });
}
// 攻击者传 amount: -1000
// sender.balance < -1000 → false(余额不会小于负数)
// sender.balance -= (-1000) → sender.balance += 1000 ✅ 余额反而增加了!
// receiver.balance += (-1000) → receiver.balance -= 1000
await db.sequelize.transaction(async (t) => {
await sender.decrement('balance', { by: amount, transaction: t });
await db.User.increment('balance', {
by: amount,
where: { id: toUserId },
transaction: t,
});
await db.Transaction.create({
fromUserId: req.user.id,
toUserId,
amount,
type: 'TRANSFER',
}, { transaction: t });
});
res.json({ balance: sender.balance });
});
3.3 攻击效果
攻击者给自己转账 amount: -99999:
- 余额不足检查通过(1000 < -99999 → false)
balance -= (-99999)→ 余额变为 100999- 空手套白狼,无限刷余额
3.4 修复方案
// FIXED: 严格的金额校验 + 数据库层面约束
app.post('/api/wallet/transfer', async (req, res) => {
const { toUserId, amount } = req.body;
// ✅ 严格的金额校验
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return res.status(400).json({ error: '金额必须为正数' });
}
if (parsedAmount > 100000) {
return res.status(400).json({ error: '单笔转账限额 10 万元' });
}
// ✅ 精度校验(避免浮点精度问题)
if (Math.round(parsedAmount * 100) !== parsedAmount * 100) {
return res.status(400).json({ error: '金额精度不能超过两位小数' });
}
if (toUserId === req.user.id) {
return res.status(400).json({ error: '不能给自己转账' });
}
await db.sequelize.transaction(async (t) => {
const sender = await db.User.findOne({
where: { id: req.user.id },
transaction: t,
lock: t.LOCK.UPDATE,
});
if (!sender || sender.balance < parsedAmount) {
throw new Error('余额不足');
}
await sender.decrement('balance', { by: parsedAmount, transaction: t });
await db.User.increment('balance', {
by: parsedAmount,
where: { id: toUserId },
transaction: t,
});
await db.Transaction.create({
fromUserId: req.user.id,
toUserId,
amount: parsedAmount,
type: 'TRANSFER',
status: 'SUCCESS',
}, { transaction: t });
});
res.json({ balance: (await db.User.findOne({ where: { id: req.user.id } })).balance });
});
3.5 数据库层面加固
除了应用层校验,数据库也要加 CHECK 约束:
-- 余额字段约束
ALTER TABLE users ADD CONSTRAINT chk_balance CHECK (balance >= 0);
-- 交易金额约束
ALTER TABLE transactions ADD CONSTRAINT chk_amount CHECK (amount > 0);
-- 使用 DECIMAL 而非 FLOAT/DOUBLE 避免精度丢失
CREATE TABLE transactions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
amount DECIMAL(15, 2) NOT NULL, -- ✅ 精确的十进制
from_user_id BIGINT NOT NULL,
to_user_id BIGINT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_amount CHECK (amount > 0)
);
四、通用防御清单
| 检查项 | 说明 |
|---|---|
| 价格信任 | 永远以后端数据库价格为准,不接受前端传来的价格 |
| 金额符号 | 所有涉及金额的接口,必须校验 amount > 0 |
| 精度控制 | 使用整数分或 DECIMAL 类型,禁止浮点数 |
| 幂等性 | 支付回调、扣款接口必须支持幂等(状态机 + 分布式锁) |
| 事务 | 多表金额操作必须在事务内执行 |
| 行级锁 | 扣减余额/库存时使用 SELECT ... FOR UPDATE |
| 签名验证 | 支付回调必须验证签名,防止伪造 |
| 限额控制 | 设置单笔/单日金额上限 |
五、总结
支付系统的核心原则:永远不要相信客户端传来的金额。每一笔涉及钱的操作,后端都必须独立计算、独立校验。重复支付的根源是没有把"订单状态"当作一道原子性的关卡,负金额攻击的根源是忘记了金额可以为负。在编码时多写三行校验,可能就省下了几十万的损失。