业务流程缺陷攻防

"流程"是所有业务系统的骨架:订单从"待支付"到"已完成"需要经过支付、发货、收货;请假申请需要经过直属上级审批、部门经理审批;退款需要经过审核、打款。流程出漏洞,攻击者就能跳过审批直接拿结果。

一、什么是状态机

几乎所有业务实体都可以抽象成状态机:

订单状态机:
  待支付 ──支付成功──▶ 待发货 ──发货──▶ 已发货 ──确认收货──▶ 已完成
     │                                                      │
     └──取消──▶ 已取消                                       └──申请退款──▶ 退款中 ──退款成功──▶ 已退款

请假审批状态机:
  待提交 ──提交──▶ 待直属上级审批 ──批准──▶ 待部门经理审批 ──批准──▶ 已通过
                       │                              │
                       └──驳回──▶ 草稿                 └──驳回──▶ 待直属上级审批

漏洞根源:攻击者不通过标准的"触发事件"来转换状态,而是直接修改状态字段。

二、漏洞一:直接修改状态字段

2.1 漏洞描述

后端允许前端直接指定目标状态,没有校验状态转换合法性。

2.2 漏洞代码(请假审批)

// VULNERABLE: 允许前端直接传任意 targetStatus
app.post('/api/approval/submit', authMiddleware, async (req, res) => {
  const { applicationId, targetStatus } = req.body;

  const application = await db.Application.findOne({ where: { id: applicationId } });
  if (!application) return res.status(404).json({ error: '申请不存在' });

  // ❌ 攻击者可以直接把状态改成 APPROVED
  // 正常需要经过两步审批,现在一步到位
  await application.update({ status: targetStatus });

  res.json({ success: true, newStatus: targetStatus });
});

2.3 攻击

POST /api/approval/submit
Authorization: Bearer USER_TOKEN

{
  "applicationId": 42,
  "targetStatus": "APPROVED"    // ❌ 跳过了所有审批步骤!
}

2.4 修复:状态转换表(State Transition Table)

// FIXED: 严格定义允许的状态转换
const APPROVAL_FLOW = {
  PENDING_SUBMIT: {
    submit: 'PENDING_SUPERVISOR',
  },
  PENDING_SUPERVISOR: {
    approve: 'PENDING_MANAGER',
    reject: 'REJECTED',
  },
  PENDING_MANAGER: {
    approve: 'APPROVED',
    reject: 'REJECTED',
  },
  REJECTED: {
    resubmit: 'PENDING_SUPERVISOR',  // 被驳回后可以重新提交
  },
  APPROVED: {},  // 终态,不允许任何转换
};

// 每个转换还需要检查角色权限
const ACTION_ROLES = {
  submit: ['EMPLOYEE'],
  approve_supervisor: ['SUPERVISOR'],
  approve_manager: ['MANAGER'],
  reject: ['SUPERVISOR', 'MANAGER'],
};

app.post('/api/approval/action', authMiddleware, async (req, res) => {
  const { applicationId, action } = req.body; // ✅ 传 action(动作),不传 targetStatus

  const application = await db.Application.findOne({
    where: { id: applicationId, applicantId: req.user.id }
  });
  if (!application) return res.status(404).json({ error: '申请不存在' });

  const currentStatus = application.status;
  const allowedTransitions = APPROVAL_FLOW[currentStatus];

  if (!allowedTransitions || !allowedTransitions[action]) {
    return res.status(400).json({ error: `当前状态 ${currentStatus} 不允许 ${action}` });
  }

  // ✅ 角色校验
  const requiredRoles = getActionRoles(action, currentStatus);
  if (!requiredRoles.includes(req.user.role)) {
    return res.status(403).json({ error: '无权执行此操作' });
  }

  // ✅ 执行转换
  const newStatus = allowedTransitions[action];
  await db.sequelize.transaction(async (t) => {
    await application.update({
      status: newStatus,
      updatedAt: new Date(),
      actionLog: db.sequelize.literal(
        `JSON_ARRAY_APPEND(action_log, '$', JSON_OBJECT('action', '${action}', 'actor', ${req.user.id}, 'time', NOW()))`
      ),
    }, { transaction: t });

    // 通知下一个审批人
    await notifyNextApprover(application, newStatus, t);
  });

  res.json({ success: true, newStatus });
});

function getActionRoles(action, currentStatus) {
  if (action === 'approve') {
    if (currentStatus === 'PENDING_SUPERVISOR') return ['SUPERVISOR'];
    if (currentStatus === 'PENDING_MANAGER') return ['MANAGER'];
  }
  return ACTION_ROLES[action] || [];
}

三、漏洞二:订单状态跳过

3.1 漏洞描述

订单状态应该是"待支付 → 待发货 → 已发货 → 已完成",但攻击者可以直接标记"已完成"来触发退款或奖励。

3.2 漏洞代码

// VULNERABLE: 普通用户可以调用任意状态转换接口
// 虽然接口名叫 /api/order/confirm,但没有校验调用者角色
app.post('/api/order/confirm', authMiddleware, async (req, res) => {
  const { orderId } = req.body;
  const order = await db.Order.findOne({ where: { id: orderId } });

  // ❌ 没有检查 order.status 是否真的是"已发货"
  // ❌ 没有检查 req.user.id === order.userId
  // ❌ 任何已登录用户都能调用

  if (order.status !== 'SHIPPED') {
    return res.status(400).json({ error: '订单未发货' });
  }

  await order.update({ status: 'COMPLETED', completedAt: new Date() });

  // 触发积分发放
  await db.User.increment('points', { by: order.totalAmount, where: { id: order.userId } });

  res.json({ success: true });
});

3.3 变种漏洞:先发货再取消

// 另一个漏洞:状态转换没有互斥
// 攻击者: 发货 → 确认收货 → 取消订单
// 正常流程: 发货 → 收货后就不能取消了

// 修复思路:状态转换必须有完整的前置条件检查
const ORDER_FLOW = {
  PENDING_PAYMENT: { pay: 'PAID', cancel: 'CANCELLED' },
  PAID: { ship: 'SHIPPED', refund: 'REFUNDING' },
  SHIPPED: { confirm: 'COMPLETED', refund: 'REFUNDING' },
  COMPLETED: { refund: 'REFUNDING' },
  REFUNDING: { refundSuccess: 'REFUNDED', refundReject: 'PAID' },
  CANCELLED: {},
  REFUNDED: {},
};

function canTransition(fromStatus, action) {
  return ORDER_FLOW[fromStatus] && ORDER_FLOW[fromStatus][action];
}

四、漏洞三:角色绕过审批

4.1 漏洞描述

"我请假需要直属上级审批,但我的直属上级是我自己(我设置的汇报对象是我自己)"——业务逻辑上的死胡同。

4.2 常见问题

  1. 审批人未设置 → 跳过审批直接通过
  2. 审批人是申请人自己 → 自审自批
  3. 审批人离职了 → 流程卡住或被自动跳过
  4. 多级审批只校验最后一级 → 前面几级形同虚设

4.3 漏洞代码

// VULNERABLE: 审批链构造错误
app.post('/api/leave/submit', authMiddleware, async (req, res) => {
  const { leaveDays, reason } = req.body;

  const leave = await db.Leave.create({
    userId: req.user.id,
    days: leaveDays,
    reason,
    status: 'PENDING',
  });

  // ❌ 只查了 CEO 审批,中间级别的 supervisor 没加
  // 或者 supervisor 字段是可选的,没填就跳过
  const ceo = await db.User.findOne({ where: { role: 'CEO' } });
  await db.LeaveApprover.create({
    leaveId: leave.id,
    approverId: ceo.id,
    order: 1,
  });

  res.json({ success: true });
});

4.4 修复:完整的审批链构造

// FIXED: 完整多级审批 + 自审批检查
async function buildApprovalChain(userId, type) {
  const chain = [];
  const employee = await db.User.findOne({ where: { id: userId } });
  const seenApprovers = new Set([userId]); // ✅ 申请人自己加入黑名单

  let current = employee;
  let depth = 0;
  const MAX_DEPTH = 10;

  while (current && depth < MAX_DEPTH) {
    // 找到当前人的直接上级
    const manager = await db.User.findOne({
      where: { departmentId: current.departmentId, level: current.level - 1 },
    });

    if (!manager) break; // 已经到顶了

    // ✅ 自审批检查:如果上级是自己或已经在审批链里,标记异常
    if (seenApprovers.has(manager.id)) {
      throw new Error('审批链异常:发现循环或自审批');
    }
    seenApprovers.add(manager.id);

    // 根据请假类型决定审批级别
    if (type === 'LEAVE' && manager.level >= 2) {
      chain.push({ approverId: manager.id, level: manager.level, role: 'MANAGER' });
    } else if (type === 'EXPENSE' && manager.level >= 3) {
      chain.push({ approverId: manager.id, level: manager.level, role: 'FINANCE_MANAGER' });
    } else {
      chain.push({ approverId: manager.id, level: manager.level, role: 'APPROVER' });
    }

    current = manager;
    depth++;
  }

  // 至少要有一级审批
  if (chain.length === 0) {
    throw new Error('无法构造审批链,请检查组织架构');
  }

  return chain;
}

app.post('/api/leave/submit', authMiddleware, async (req, res) => {
  const { leaveDays, reason } = req.body;

  await db.sequelize.transaction(async (t) => {
    const chain = await buildApprovalChain(req.user.id, 'LEAVE');

    const leave = await db.Leave.create({
      userId: req.user.id,
      days: leaveDays,
      reason,
      status: `PENDING_APPROVAL_1`,
      totalApprovals: chain.length,
    }, { transaction: t });

    for (let i = 0; i < chain.length; i++) {
      await db.LeaveApprover.create({
        leaveId: leave.id,
        approverId: chain[i].approverId,
        order: i + 1,
        role: chain[i].role,
        status: i === 0 ? 'PENDING' : 'WAITING', // 只有第一个是 PENDING,后面的等待
      }, { transaction: t });
    }
  });

  res.json({ success: true, message: '已提交,等待审批' });
});

五、漏洞四:并发导致的状态混乱

5.1 场景一:退款与发货并发

时间线:
  t1: 用户申请退款,状态 → REFUNDING
  t2: 管理员点击发货,没有检查最新状态(读到的还是 PAID)
  t3: 发货成功,发了货
  t4: 退款成功,钱退了

结果: 用户既拿到了货,又退了钱

5.2 场景二:审批并发

t1: 审批人A 看到状态 PENDING_SUPERVISOR
t2: 审批人B 也看到状态 PENDING_SUPERVISOR
t3: A 批准 → 状态变成 PENDING_MANAGER
t4: B 也批准 → 状态又变?取决于代码怎么写

5.3 修复:乐观锁 / 条件更新

// 用 version 字段做乐观锁
app.post('/api/order/ship', authMiddleware, async (req, res) => {
  const { orderId } = req.body;

  const [affectedCount, updatedRows] = await db.Order.update(
    {
      status: 'SHIPPED',
      shippedAt: new Date(),
      version: db.sequelize.literal('version + 1'), // 乐观锁
    },
    {
      where: {
        id: orderId,
        status: 'PAID',    // ✅ 只有 PAID 才能发货
        version: req.body.version, // ✅ 前端传来的版本号必须匹配
      },
    }
  );

  if (affectedCount === 0) {
    // 要么状态已经变了(被退款了),要么版本号不匹配
    const order = await db.Order.findOne({ where: { id: orderId } });
    return res.status(400).json({
      error: `订单当前状态 ${order.status},无法发货`,
    });
  }

  // 发货逻辑...
  res.json({ success: true });
});

// 审批场景:条件更新确保只有一个审批人能推进状态
async function approveApplication(applicationId, approverId) {
  const result = await db.sequelize.transaction(async (t) => {
    const record = await db.ApprovalRecord.findOne({
      where: { applicationId, approverId, status: 'PENDING' },
      transaction: t,
      lock: t.LOCK.UPDATE,  // ✅ 行级锁,防止并发审批
    });

    if (!record) {
      throw new Error('不是当前审批人或已处理');
    }

    // 更新当前审批记录
    await record.update({ status: 'APPROVED', decidedAt: new Date() }, { transaction: t });

    // 推进到下一个审批人
    const nextRecord = await db.ApprovalRecord.findOne({
      where: { applicationId, order: record.order + 1 },
      transaction: t,
    });

    if (nextRecord) {
      await nextRecord.update({ status: 'PENDING' }, { transaction: t });
      await db.Application.update(
        { status: `PENDING_APPROVAL_${nextRecord.order}` },
        { where: { id: applicationId }, transaction: t }
      );
    } else {
      // 最后一级审批通过
      await db.Application.update(
        { status: 'APPROVED', approvedAt: new Date() },
        { where: { id: applicationId }, transaction: t }
      );
    }
  });

  return result;
}

六、漏洞五:撤销 / 撤回绕过

6.1 场景:请假已审批通过,但申请人撤回了

// VULNERABLE: 撤回接口没有限制终态
app.post('/api/leave/withdraw', authMiddleware, async (req, res) => {
  const { leaveId } = req.body;
  const leave = await db.Leave.findOne({ where: { id: leaveId, userId: req.user.id } });

  // ❌ 什么状态都能撤回,包括已经审批通过的
  await leave.update({ status: 'WITHDRAWN' });

  // 更糟:如果系统自动销假,撤回后又可以重新请
  res.json({ success: true });
});

// FIXED: 只有特定状态才能撤回
const WITHDRAW_ALLOWED_STATUS = ['PENDING_APPROVAL_1', 'PENDING_APPROVAL_2'];

app.post('/api/leave/withdraw', authMiddleware, async (req, res) => {
  const { leaveId } = req.body;
  const leave = await db.Leave.findOne({ where: { id: leaveId, userId: req.user.id } });

  if (!leave) return res.status(404).json({ error: '申请不存在' });

  if (!WITHDRAW_ALLOWED_STATUS.includes(leave.status)) {
    return res.status(400).json({
      error: `当前状态 ${leave.status} 不允许撤回`,
    });
  }

  await leave.update({ status: 'WITHDRAWN' });
  res.json({ success: true });
});

七、安全流程设计模式汇总

7.1 状态机引擎(推荐)

class StateMachine {
  constructor(transitions) {
    this.transitions = transitions;
  }

  canTransition(fromState, event) {
    return this.transitions[fromState]?.[event] !== undefined;
  }

  transition(obj, event, actor) {
    const current = obj.status;
    const allowed = this.transitions[current];
    if (!allowed || !allowed[event]) {
      throw new Error(`状态 ${current} 不允许事件 ${event}`);
    }

    const next = allowed[event];
    obj.status = next;
    obj.updatedAt = new Date();
    obj.lastAction = { event, actor, at: new Date() };
    return next;
  }
}

// 使用:订单状态机
const orderMachine = new StateMachine({
  PENDING_PAYMENT: { pay: 'PAID', cancel: 'CANCELLED' },
  PAID: { ship: 'SHIPPED', refund: 'REFUNDING' },
  SHIPPED: { confirm: 'COMPLETED', refund: 'REFUNDING' },
  COMPLETED: { refund: 'REFUNDING' },
  REFUNDING: { refundSuccess: 'REFUNDED', refundReject: (from) => from === 'REFUNDING' ? 'PAID' : from },
  CANCELLED: {},
  REFUNDED: {},
});

// 用法:不让前端传 targetStatus,只传 action
const nextStatus = orderMachine.transition(order, 'ship', req.user.id);
// 自动校验:PAID → SHIPPED ✅
// 自动拒绝:COMPLETED → ship ❌

7.2 通用防御原则

原则 实现
不让前端传目标状态 前端只传 action(动作),后端查表得到目标状态
状态转换表 每种状态明确定义允许的事件和目标状态
角色-动作绑定 每种动作限制特定角色才能执行
乐观锁 / 行级锁 并发操作时保证只有一个成功
终态不可变 APPROVED、COMPLETED 等终态不允许任何转换
审批链完整校验 防止自审批、循环审批、跳过中间级别
动作留痕 每次状态变更记录完整的 action log
回滚前置检查 退款/撤回等反向操作必须检查前置条件

八、总结

业务流程漏洞的本质是:开发者把状态当成了一个可以自由赋值的字段,而不是一个受约束的状态机节点。

修复流程漏洞的正确思路不是到处加 if 判断,而是引入显式的状态机模型——在系统层面定义清楚"从什么状态、允许什么动作、到什么状态、谁能做"。

业务逻辑安全的三个层次:

  1. 数据层:CHECK 约束、外键、唯一索引
  2. 逻辑层:状态机引擎、转换表、乐观锁
  3. 角色层:RBAC + 动作绑定,谁能做什么事

把这三层搭起来,大部分流程漏洞就自然消失了。