业务逻辑漏洞测试清单¶
逻辑漏洞没有扫描器能替你发现,因为"错误"取决于业务规则。测试的核心动作是:把服务端当成不可信的一方,逐个假设它没校验,然后用最小请求证明。
下面每类都给:攻击链 → 复现方式 → 成立证据 → 修复代码。
一、支付与金额¶
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.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 支付回调伪造与重复¶
三个测试方向:
- 不验签:把
sign删掉或改成任意值,回调是否仍被处理。 - 金额不校验:订单应付 100 元,回调
total_amount=0.01,订单是否变已支付。 - 重复回调:同一笔回调重放 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 六个测试点¶
- 可复用:同一验证码登录 2 次都成功。
- 不绑定:A 手机号的验证码能验证 B 手机号。
- 不过期:等 10 分钟仍有效。
- 可爆破:4 位码无次数限制。
- 响应泄露:接口返回
{"code":0,"smsCode":"1234"}。 - 万能码:
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 而不是账号)
- 用户名加空格/大小写/全角字符绕过计数(
adminvsadminvsAdmin) - 锁定只在前端提示,后端仍校验密码
验证:对同一账号从 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 跳过前置步骤¶
测试方法:把每个状态下的所有接口都试一遍,不只看前端按钮。接口清单来自 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 改成自己。
七、测试执行方法¶
- 画状态机:把订单/提现/退款的状态与允许动作画出来,标出每个动作的接口。
- 列参数表:每个接口列出所有可篡改字段(金额、数量、ID、状态、时间)。
- 四种变形:改参数、换身份、调顺序、并发/重放。
- 对照实验:每次只改一个变量,保留原始响应做 diff。
- 看服务端:前端提示不算证据,以数据库/对账/流水为准。
八、修复原则清单¶
- 金额、数量、库存、状态一律服务端计算,客户端值只做参考
- 金额用
BigDecimal/ 整数分,禁止double - 所有扣减用条件更新或锁,禁止"先查再改"
- 关键写操作有幂等键 + 唯一索引
- 状态流转用状态机守卫 + 条件更新
- 验证码绑定场景、一次性、有频控
- 重置 token 随机、服务端绑定用户、一次性
- 回调验签 + 金额校验 + 幂等
- 优惠券/积分/退款有累计上限校验
- 所有内部接口与定时任务也在鉴权范围内