跳转到内容
123xiao | 无名键客

《从签名参数到请求重放:中级工程师实战拆解 Web 逆向中的常见加密校验链路》

字数: 0 阅读时长: 1 分钟

从签名参数到请求重放:中级工程师实战拆解 Web 逆向中的常见加密校验链路

很多工程师第一次接触 Web 逆向时,会把注意力全部放在“加密算法”本身,比如 MD5、SHA256、AES、RSA。可一旦真上手抓包、调试、重放请求,就会发现:真正拦路的,往往不是算法名字,而是整条校验链路怎么串起来的

也就是说,你看到的 signtokennoncetsx-sign 这些参数,并不是孤零零存在的。它们通常和:

  • 请求参数排序
  • 时间戳
  • 随机串
  • Header 参与签名
  • Cookie / Session 绑定
  • 请求体序列化方式
  • 重放保护策略

一起组成一个完整的校验系统。

这篇文章我不打算只讲“某个 sign 怎么算”,而是从签名参数到请求重放,把 Web 逆向里最常见的加密校验链路拆开讲清楚,顺带给出一套可运行的示例代码,帮助你形成可复用的排查思路。

说明:本文讨论的是常见 Web 客户端签名与校验机制的技术分析方法,用于学习协议设计、接口调试、安全测试与自有系统联调。请仅在合法授权范围内使用。


背景与问题

一个典型场景是这样的:

你抓到某个接口请求,参数看起来很普通,但服务端返回:

  • invalid sign
  • signature expired
  • request replay detected
  • forbidden
  • token verify failed

你以为只要把 sign 算对就行,结果重放时还是失败。接着你发现:

  1. 同样的参数,每次 sign 都不一样
  2. 换一个时间戳就不通过
  3. 直接复制浏览器请求,在脚本里重放也失败
  4. 浏览器里成功,抓包工具里失败
  5. 参数顺序稍微变一下,签名立刻失效

这说明问题不只是“一个加密函数”,而是请求被服务端按某种规范重新组装和校验

常见校验链路长什么样

最常见的一类流程是:

  1. 客户端准备业务参数
  2. 加入 ts(时间戳)、nonce(随机串)
  3. 按固定规则拼接参数
  4. 用密钥或派生值做摘要 / HMAC
  5. sign 连同原始参数一起提交
  6. 服务端按同样规则重新计算
  7. 校验时间窗口、随机串唯一性、签名一致性
  8. 通过后才处理业务逻辑

这条链路里,任何一个环节理解错了,请求重放都会失败。


前置知识

如果你具备下面这些基础,阅读会更顺手:

  • 会抓包,知道如何看请求头、Cookie、请求体
  • 能读一点 JavaScript,至少看懂对象、字符串拼接、哈希调用
  • 对 HTTP 方法、状态码、Header 有基本理解
  • 知道时间戳、随机数、摘要算法的基本概念

不要求你是密码学专家。大多数 Web 逆向里,用到的是“工程化签名校验”,不是严格的密码协议分析。


环境准备

本文示例使用 Python,方便快速演示服务端和重放端。

安装依赖:

pip install flask requests

如果你想顺手验证前端逻辑,也可以准备一个 Node.js 环境,不过本文主线用 Python 就够了。


核心原理

我们先把核心原理拆成 4 个问题。

1. 签名参数到底在保护什么

很多人看到 sign,第一反应是“防爬”。其实从工程角度看,签名参数通常至少保护这几件事:

  • 参数未被篡改
  • 请求来自知道算法的一方
  • 请求在有效时间窗口内
  • 请求不是简单复制的重复包

所以它不是单点防护,而是一个组合拳。

2. 常见签名输入由哪些部分组成

一个常见的签名输入串,可能包含:

  • Query 参数
  • Body 参数
  • Header 中的某些字段
  • 时间戳 ts
  • 随机串 nonce
  • 用户身份信息,如 uidtoken
  • 固定密钥 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 是否与当前 tsnonce 匹配
  • 签名是否和当前登录态绑定
  • 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 有没有变化

通常你很快能圈出可疑字段:

  • sign
  • ts
  • nonce
  • traceId
  • x-sign
  • x-timestamp
  • x-token

如果某个字段每次都变,而且长度固定、字符集像十六进制或 Base64,那它大概率参与校验。

第二步:判断是“摘要”还是“加密”

经验上:

  • 长度 32 的十六进制:常见 MD5
  • 长度 40:常见 SHA1
  • 长度 64:常见 SHA256
  • Base64 很长一串:可能是 AES / RSA / HMAC 后再编码
  • 明显和业务参数强相关:通常是摘要签名
  • 整个请求体都变成密文:更像加密而不是签名

但别太迷信长度。很多系统会再做截断、大小写转换、二次编码。

第三步:在前端代码里找“签名入口”

搜索关键词很有用:

  • sign
  • signature
  • token
  • timestamp
  • nonce
  • md5
  • sha1
  • sha256
  • hmac
  • CryptoJS
  • sort
  • stringify

如果是打包后的前端代码,别急着读完整文件,先找:

  • 发请求的位置
  • 请求拦截器
  • 公共 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 + 重放保护”的完整链路。

我们会写两个部分:

  1. 一个 Flask 服务端
  2. 一个 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-Agent
  • X-Token
  • X-Timestamp
  • Origin
  • Referer

有些系统甚至会把 HTTP 方法和路径一起签入。

同样的签名参数,换一个会话可能就失效。因为服务端会把当前用户态一起参与校验,哪怕你没在包里直接看到。


常见坑与排查

这一部分我尽量写得“接地气”一点,因为真正做逆向时,坑往往比算法本身多。

坑 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 是原文还是对象?
  • tsnonce 是否参与?
  • 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 绑定
  • 服务端重算校验
  • 风控附加检查

所以中级工程师要突破的,不是死记某个算法,而是建立一套稳定的拆解方法:

  1. 先对比多次请求,找出变动字段
  2. 定位签名入口,而不是只搜算法函数
  3. 先还原输入串,再验证算法
  4. 重放失败时,分清是签名错、时间失效、nonce 重复,还是会话/风控问题
  5. 用可打印、可对比的最小脚本逐步逼近真实请求

如果你能把“签名生成”和“请求重放保护”当成一条链来看,很多原本看似玄学的问题,其实都能落回到工程细节上。

最后给一个实用建议:每次分析接口时,都把“最终参与签名的原始字符串”当作核心证据保存下来。因为只要这个字符串对上了,算法通常不是问题;如果这个字符串对不上,后面做多少哈希都没意义。


分享到:

上一篇
《从抓包到还原签名:中级开发者实战 Web 逆向中的前端加密参数分析与自动化复现》
下一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-318》