从签名参数到请求重放:中级工程师实战拆解 Web 逆向中的常见加密校验链路
很多工程师第一次接触 Web 逆向时,会把注意力全部放在“加密算法”本身,比如 MD5、SHA256、AES、RSA。可一旦真上手抓包、调试、重放请求,就会发现:真正拦路的,往往不是算法名字,而是整条校验链路怎么串起来的。
也就是说,你看到的 sign、token、nonce、ts、x-sign 这些参数,并不是孤零零存在的。它们通常和:
- 请求参数排序
- 时间戳
- 随机串
- Header 参与签名
- Cookie / Session 绑定
- 请求体序列化方式
- 重放保护策略
一起组成一个完整的校验系统。
这篇文章我不打算只讲“某个 sign 怎么算”,而是从签名参数到请求重放,把 Web 逆向里最常见的加密校验链路拆开讲清楚,顺带给出一套可运行的示例代码,帮助你形成可复用的排查思路。
说明:本文讨论的是常见 Web 客户端签名与校验机制的技术分析方法,用于学习协议设计、接口调试、安全测试与自有系统联调。请仅在合法授权范围内使用。
背景与问题
一个典型场景是这样的:
你抓到某个接口请求,参数看起来很普通,但服务端返回:
invalid signsignature expiredrequest replay detectedforbiddentoken verify failed
你以为只要把 sign 算对就行,结果重放时还是失败。接着你发现:
- 同样的参数,每次
sign都不一样 - 换一个时间戳就不通过
- 直接复制浏览器请求,在脚本里重放也失败
- 浏览器里成功,抓包工具里失败
- 参数顺序稍微变一下,签名立刻失效
这说明问题不只是“一个加密函数”,而是请求被服务端按某种规范重新组装和校验。
常见校验链路长什么样
最常见的一类流程是:
- 客户端准备业务参数
- 加入
ts(时间戳)、nonce(随机串) - 按固定规则拼接参数
- 用密钥或派生值做摘要 / HMAC
- 把
sign连同原始参数一起提交 - 服务端按同样规则重新计算
- 校验时间窗口、随机串唯一性、签名一致性
- 通过后才处理业务逻辑
这条链路里,任何一个环节理解错了,请求重放都会失败。
前置知识
如果你具备下面这些基础,阅读会更顺手:
- 会抓包,知道如何看请求头、Cookie、请求体
- 能读一点 JavaScript,至少看懂对象、字符串拼接、哈希调用
- 对 HTTP 方法、状态码、Header 有基本理解
- 知道时间戳、随机数、摘要算法的基本概念
不要求你是密码学专家。大多数 Web 逆向里,用到的是“工程化签名校验”,不是严格的密码协议分析。
环境准备
本文示例使用 Python,方便快速演示服务端和重放端。
安装依赖:
pip install flask requests
如果你想顺手验证前端逻辑,也可以准备一个 Node.js 环境,不过本文主线用 Python 就够了。
核心原理
我们先把核心原理拆成 4 个问题。
1. 签名参数到底在保护什么
很多人看到 sign,第一反应是“防爬”。其实从工程角度看,签名参数通常至少保护这几件事:
- 参数未被篡改
- 请求来自知道算法的一方
- 请求在有效时间窗口内
- 请求不是简单复制的重复包
所以它不是单点防护,而是一个组合拳。
2. 常见签名输入由哪些部分组成
一个常见的签名输入串,可能包含:
- Query 参数
- Body 参数
- Header 中的某些字段
- 时间戳
ts - 随机串
nonce - 用户身份信息,如
uid、token - 固定密钥
secret
比如:
app_id=demo&nonce=abc123&ts=1699999999&user_id=42&secret=my_secret
或者:
POST\n/api/order/create\n1699999999\nabc123\n{"sku":"1001","count":2}
关键点:服务端一定会“规范化”
真正决定成败的不是你“看见了什么”,而是服务端怎么规范化输入。常见规范化动作有:
- 参数按字典序排序
- 忽略空值参数
- 数字和字符串统一转成字符串
- JSON key 排序
- 去掉多余空格
- URL 编码前签还是编码后签
- Body 原文签名还是解析后签名
这个点我当时踩过坑:抓到的请求里,前端发送的 JSON 顺序是 b,a,我在脚本里构造对象后变成了 a,b,结果签名怎么都不对。后来才发现服务端是按原文字符串签,而不是按解析后的对象签。
3. 为什么“重放同一个请求”会失败
因为服务端一般不会只验证 sign,还会做这些检查:
ts是否在允许范围内,比如 ±300 秒nonce是否已使用过sign是否与当前ts、nonce匹配- 签名是否和当前登录态绑定
- Cookie / Token 是否已过期
- 某些字段是否一次性消费
所以请求重放失败,并不意味着算法错了,可能是重放条件失效了。
4. 一张图看清整条链路
flowchart TD
A[抓包获取请求] --> B[识别关键参数 sign ts nonce token]
B --> C[定位签名生成逻辑]
C --> D[还原参数规范化规则]
D --> E[复现签名算法]
E --> F[构造新请求]
F --> G[校验是否可重放]
G --> H{失败原因}
H -->|签名不一致| I[检查排序 编码 拼接 JSON 序列化]
H -->|时间失效| J[更新时间戳]
H -->|重放保护| K[刷新 nonce 或会话]
H -->|身份绑定| L[检查 token cookie header]
从抓包到还原:分析思路
在实战里,我一般按下面顺序排查,而不是一上来就盯着混淆代码。
第一步:先找“变动参数”
连续发两次相同操作,抓两份包,对比:
- 哪些参数固定不变
- 哪些参数每次变化
- 哪些 Header 每次变化
- Cookie 有没有变化
通常你很快能圈出可疑字段:
signtsnoncetraceIdx-signx-timestampx-token
如果某个字段每次都变,而且长度固定、字符集像十六进制或 Base64,那它大概率参与校验。
第二步:判断是“摘要”还是“加密”
经验上:
- 长度 32 的十六进制:常见 MD5
- 长度 40:常见 SHA1
- 长度 64:常见 SHA256
- Base64 很长一串:可能是 AES / RSA / HMAC 后再编码
- 明显和业务参数强相关:通常是摘要签名
- 整个请求体都变成密文:更像加密而不是签名
但别太迷信长度。很多系统会再做截断、大小写转换、二次编码。
第三步:在前端代码里找“签名入口”
搜索关键词很有用:
signsignaturetokentimestampnoncemd5sha1sha256hmacCryptoJSsortstringify
如果是打包后的前端代码,别急着读完整文件,先找:
- 发请求的位置
- 请求拦截器
- 公共 util
- Header 注入逻辑
很多项目会在 axios/fetch 的请求拦截器里统一加签。
签名链路示意图
sequenceDiagram
participant C as Client
participant S as Server
participant N as NonceStore
C->>C: 生成 ts / nonce
C->>C: 排序参数并拼接
C->>C: HMAC(secret, canonical_string)
C->>S: 发送 params + ts + nonce + sign
S->>S: 校验 ts 是否过期
S->>N: 查询 nonce 是否已使用
N-->>S: 未使用
S->>S: 重新拼接并计算 sign
S->>S: 对比 sign
S->>N: 记录 nonce
S-->>C: 返回业务结果
实战代码(可运行)
下面我用一个简化但足够典型的例子,模拟“签名 + 时间戳 + nonce + 重放保护”的完整链路。
我们会写两个部分:
- 一个 Flask 服务端
- 一个 Python 客户端,先正常请求,再演示重放失败
服务端示例
保存为 server.py:
from flask import Flask, request, jsonify
import time
import hmac
import hashlib
import json
app = Flask(__name__)
SECRET = "demo_secret_key"
NONCE_STORE = set()
MAX_TIME_SKEW = 300 # 允许 5 分钟时间偏差
def canonicalize(params: dict) -> str:
"""
参数规范化:
1. 排除 sign 字段
2. 排除值为 None 的字段
3. 按 key 排序
4. 用 key=value&key=value 拼接
"""
items = []
for k, v in params.items():
if k == "sign" or v is None:
continue
items.append((str(k), str(v)))
items.sort(key=lambda x: x[0])
return "&".join([f"{k}={v}" for k, v in items])
def make_sign(params: dict) -> str:
canonical = canonicalize(params)
return hmac.new(
SECRET.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
@app.route("/api/order", methods=["POST"])
def order():
data = request.get_json(force=True, silent=True) or {}
required_fields = ["user_id", "item_id", "ts", "nonce", "sign"]
for field in required_fields:
if field not in data:
return jsonify({"code": 4001, "msg": f"missing field: {field}"}), 400
# 1. 校验时间戳
try:
ts = int(data["ts"])
except ValueError:
return jsonify({"code": 4002, "msg": "invalid ts"}), 400
now = int(time.time())
if abs(now - ts) > MAX_TIME_SKEW:
return jsonify({"code": 4003, "msg": "signature expired"}), 403
# 2. 校验 nonce 是否重放
nonce = data["nonce"]
if nonce in NONCE_STORE:
return jsonify({"code": 4004, "msg": "request replay detected"}), 403
# 3. 校验签名
expected_sign = make_sign(data)
if not hmac.compare_digest(expected_sign, data["sign"]):
return jsonify({
"code": 4005,
"msg": "invalid sign",
"expected": expected_sign
}), 403
# 4. 记录 nonce,表示该请求已消费
NONCE_STORE.add(nonce)
return jsonify({
"code": 0,
"msg": "success",
"data": {
"order_id": f"OD{now}",
"user_id": data["user_id"],
"item_id": data["item_id"]
}
})
if __name__ == "__main__":
app.run(port=5000, debug=True)
启动服务:
python server.py
客户端示例
保存为 client.py:
import time
import uuid
import hmac
import hashlib
import requests
SECRET = "demo_secret_key"
URL = "http://127.0.0.1:5000/api/order"
def canonicalize(params: dict) -> str:
items = []
for k, v in params.items():
if k == "sign" or v is None:
continue
items.append((str(k), str(v)))
items.sort(key=lambda x: x[0])
return "&".join([f"{k}={v}" for k, v in items])
def make_sign(params: dict) -> str:
canonical = canonicalize(params)
return hmac.new(
SECRET.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
def build_payload(user_id: int, item_id: int):
payload = {
"user_id": user_id,
"item_id": item_id,
"ts": int(time.time()),
"nonce": uuid.uuid4().hex[:16],
}
payload["sign"] = make_sign(payload)
return payload
def main():
# 第一次正常请求
payload = build_payload(1001, 2002)
print("第一次请求 payload:", payload)
r1 = requests.post(URL, json=payload, timeout=5)
print("第一次响应:", r1.status_code, r1.text)
# 第二次直接重放同一请求
print("\n第二次重放同一 payload")
r2 = requests.post(URL, json=payload, timeout=5)
print("第二次响应:", r2.status_code, r2.text)
# 第三次刷新 nonce 和 ts,并重新计算 sign
print("\n第三次:更新 ts / nonce / sign 后再请求")
payload2 = {
"user_id": payload["user_id"],
"item_id": payload["item_id"],
"ts": int(time.time()),
"nonce": uuid.uuid4().hex[:16],
}
payload2["sign"] = make_sign(payload2)
r3 = requests.post(URL, json=payload2, timeout=5)
print("第三次响应:", r3.status_code, r3.text)
if __name__ == "__main__":
main()
运行:
python client.py
你会看到类似结果:
- 第一次成功
- 第二次报
request replay detected - 第三次更新
ts/nonce/sign后恢复成功
逐步验证清单
如果你在真实项目里要复现签名,不妨按这份清单一步步过:
1. 先固定参数
保证业务参数不变,只观察签名相关字段变化。
2. 验证参与签名的字段集合
确认到底是:
- 只签 Query
- 只签 Body
- Query + Body
- Header + Body
- 路径 + 方法 + Body
3. 验证参数顺序
看是否按:
- 原顺序
- 字典序
- 自定义顺序
4. 验证编码方式
特别关注:
- URL 编码前还是后
- 空格是
%20还是+ - 中文是否参与编码
- JSON 是否去空格
5. 验证时间戳格式
常见有:
- 秒级时间戳:
1699999999 - 毫秒时间戳:
1699999999123 - ISO 时间字符串
6. 验证 nonce 规则
有些是:
- UUID
- 随机十六进制
- 固定长度字母数字串
- 前端生成 + 服务端绑定会话
7. 验证摘要算法与输出格式
例如:
- MD5 小写
- MD5 大写
- SHA256 hex
- HMAC-SHA256 Base64
8. 验证请求重放策略
重放失败时,判断是:
nonce重复ts过期- 会话失效
- sign 重新计算后不匹配
进阶:为什么同样算法,脚本和浏览器结果不一致
这类问题特别常见,而且最耗时间。因为看起来你“算法完全一样”,实际输入串已经不一样了。
1. JSON 序列化不一致
浏览器里可能是:
{"b":2,"a":1}
你脚本里却变成:
{"a":1,"b":2}
如果服务端签的是原始字符串,这两个签名一定不同。
2. 布尔值、数字、空值处理不同
有些前端会把:
true转成"true"1转成"1"null直接忽略undefined不参与签名
你脚本里如果照搬对象结构,不做同样转换,就会偏。
3. Header 参与了签名
很多人只盯着请求体,漏了这些 Header:
User-AgentX-TokenX-TimestampOriginReferer
有些系统甚至会把 HTTP 方法和路径一起签入。
4. Cookie / Session 绑定
同样的签名参数,换一个会话可能就失效。因为服务端会把当前用户态一起参与校验,哪怕你没在包里直接看到。
常见坑与排查
这一部分我尽量写得“接地气”一点,因为真正做逆向时,坑往往比算法本身多。
坑 1:只改了 ts,忘了重算 sign
这是最常见的低级坑。签名里如果包含时间戳,ts 变了,sign 必须一起变。
排查建议
把签名输入串打印出来,一眼就能发现当前 ts 是否已参与拼接。
坑 2:参数顺序看似无关,实际上强相关
比如这两个字符串:
a=1&b=2
b=2&a=1
对哈希来说完全不同。
排查建议
同时输出:
- 原始参数字典
- 排序后的参数列表
- 最终拼接串
- 最终 sign
别只盯着最终 sign。
坑 3:抓包工具重放成功,代码重放失败
这通常说明你代码构造请求时,和原始请求存在差异,比如:
- Header 缺失
- Cookie 不一致
- Content-Type 不同
- Body 编码方式不同
排查建议
拿抓包工具导出的 raw request,对比脚本真正发出的请求全文。
坑 4:前端代码找到了 md5,但它不是最终签名
很多项目会有多层处理:
- 先拼串
- 再 md5
- 再加盐
- 再转大写
- 再和 token 拼接
- 最后再 sha1
你如果只截到中间那一步,当然复现不出来。
排查建议
不要只搜算法函数,要顺藤摸瓜往上找调用链,往下看返回值被谁继续处理。
坑 5:重放失败不是签名错,而是风控拦截
比如:
- IP 异常
- UA 异常
- 请求频率异常
- Referer 缺失
- TLS 指纹变化
这时候接口可能仍然返回“签名错误”之类的泛化提示,误导你去查算法。
排查建议
做最小变量控制:先在同一环境、同一会话、同一 UA 下测试,只改一个字段。
一个排查状态图
stateDiagram-v2
[*] --> 抓包成功
抓包成功 --> 提取关键参数
提取关键参数 --> 复现签名
复现签名 --> 请求发送
请求发送 --> 校验通过
请求发送 --> 校验失败
校验失败 --> 检查参数排序
校验失败 --> 检查时间戳
校验失败 --> 检查nonce重放
校验失败 --> 检查Header或Cookie
校验失败 --> 检查JSON序列化
校验失败 --> 检查风控因素
检查参数排序 --> 复现签名
检查时间戳 --> 复现签名
检查nonce重放 --> 复现签名
检查Header或Cookie --> 请求发送
检查JSON序列化 --> 复现签名
检查风控因素 --> 请求发送
校验通过 --> [*]
安全/性能最佳实践
这一节不仅适合做逆向分析,也适合你自己设计接口签名体系时参考。
1. 不要把“加密”和“签名”混为一谈
- 签名:强调完整性和来源校验
- 加密:强调内容保密
很多 Web 接口根本不需要“把所有参数 AES 加密”,真正需要的是:
- HTTPS 传输
- 服务端签名校验
- 时效控制
- 重放保护
如果系统设计错位,前端再复杂也只是徒增维护成本。
2. 签名规则必须稳定、可文档化
如果你自己设计接口,建议明确写清楚:
- 参与签名字段
- 是否排序
- 排序规则
- 空值处理
- JSON 序列化规则
- 时间戳格式
- 签名算法
- 输出编码格式
否则联调时两边各自“理解正确”,结果就是总对不上。
3. 重放保护不要只靠时间戳
只看 ts,攻击者在有效窗口内还是可以重复请求。更稳妥的方案是:
ts+nonce- 服务端存储已用
nonce - 设置过期时间
- 对敏感接口做幂等控制
但也要注意性能边界
nonce 去重需要存储,常见做法是放到 Redis,并设置合理 TTL。
如果请求量大,TTL 设计和 key 空间都要考虑,否则去重本身会成为负担。
4. 比较签名时使用安全比较函数
服务端不要直接:
if expected == provided:
...
而应该尽量使用类似:
hmac.compare_digest(expected, provided)
这是个小细节,但属于比较规范的安全习惯。
5. 前端密钥不是“绝对秘密”
如果签名逻辑放在前端,理论上总能被分析出来。所以前端签名的价值更多在于:
- 提高滥用成本
- 过滤低质量流量
- 配合服务端风控
不要指望“前端藏一个 secret”就万无一失。真正敏感的校验,还是应该由服务端掌控。
6. 逆向排查时,优先追“输入串”,而不是追“算法名”
这是我最想给中级工程师的建议。
因为在大多数实战里:
- 算法不难认
- 难的是输入怎么拼
- 更难的是还有没有附加约束
所以与其问“这是 MD5 还是 SHA256”,不如先问:
- 它到底签了哪些字段?
- 字段顺序是什么?
- JSON 是原文还是对象?
ts和nonce是否参与?- Header 是否进签名?
这个思路一旦建立起来,很多问题会突然变简单。
一个更贴近实战的最小排查模板
你可以把下面这个模板直接套到自己的项目里,用于打印签名输入。
def debug_sign(params, secret):
items = []
for k, v in params.items():
if k == "sign" or v is None:
continue
items.append((str(k), str(v)))
print("原始参数:", params)
print("排序前列表:", items)
items.sort(key=lambda x: x[0])
print("排序后列表:", items)
canonical = "&".join([f"{k}={v}" for k, v in items])
print("canonical:", canonical)
sign = hmac.new(
secret.encode(),
canonical.encode(),
hashlib.sha256
).hexdigest()
print("sign:", sign)
return sign
当你复现失败时,把这个打印结果和浏览器侧、抓包侧逐项对比,通常很快能定位偏差。
总结
Web 逆向里,所谓“签名参数”很少是一个独立点,它往往嵌在一整条加密校验链路中:
- 参数规范化
- 时间戳控制
- nonce 防重放
- Header / Cookie 绑定
- 服务端重算校验
- 风控附加检查
所以中级工程师要突破的,不是死记某个算法,而是建立一套稳定的拆解方法:
- 先对比多次请求,找出变动字段
- 定位签名入口,而不是只搜算法函数
- 先还原输入串,再验证算法
- 重放失败时,分清是签名错、时间失效、nonce 重复,还是会话/风控问题
- 用可打印、可对比的最小脚本逐步逼近真实请求
如果你能把“签名生成”和“请求重放保护”当成一条链来看,很多原本看似玄学的问题,其实都能落回到工程细节上。
最后给一个实用建议:每次分析接口时,都把“最终参与签名的原始字符串”当作核心证据保存下来。因为只要这个字符串对上了,算法通常不是问题;如果这个字符串对不上,后面做多少哈希都没意义。