支付逻辑漏洞全解析

支付模块是电商系统的核心,一旦出现逻辑漏洞,攻击者可以用 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 攻击流程

  1. 正常下单:选择商品 → 点击购买 → 抓包
  2. 拦截请求,将 price: 5999 改为 price: 0.01
  3. 提交 → 生成金额为 0.01 元的订单
  4. 完成支付,成功以一分钱购得商品

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

  1. 余额不足检查通过(1000 < -99999 → false)
  2. balance -= (-99999) → 余额变为 100999
  3. 空手套白狼,无限刷余额

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
签名验证 支付回调必须验证签名,防止伪造
限额控制 设置单笔/单日金额上限

五、总结

支付系统的核心原则:永远不要相信客户端传来的金额。每一笔涉及钱的操作,后端都必须独立计算、独立校验。重复支付的根源是没有把"订单状态"当作一道原子性的关卡,负金额攻击的根源是忘记了金额可以为负。在编码时多写三行校验,可能就省下了几十万的损失。