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

《Web逆向实战:从请求重放到参数还原,系统定位前端签名与加密逻辑》

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

背景与问题

做 Web 逆向时,最常见的场景不是“看不懂代码”,而是“请求明明长得差不多,重放就是失败”。
很多接口表面上只是多了几个参数,比如:

  • sign
  • token
  • t
  • nonce
  • cipher
  • data

但真正麻烦的是:这些字段往往不是固定值,而是由前端脚本基于时间戳、随机数、请求体、设备信息、Cookie、局部状态共同计算出来的。

于是问题会变成这样:

  1. 抓到了接口请求,直接在 Postman 或脚本里重放,失败;
  2. 修改少量业务参数后,请求返回“签名错误”;
  3. 页面代码经过压缩混淆,搜索 sign 找不到有效线索;
  4. 就算知道用了 md5/aes,也不清楚输入原文是什么、拼接顺序是什么、密钥从哪来

这篇文章不只是讲“怎么找一个 sign 函数”,而是从体系化定位的角度,带你走一遍:

  • 如何从“请求重放失败”反推出有签名/加密层;
  • 如何从网络请求、调用栈、Hook、AST 搜索几条线并行定位;
  • 如何把“看似混乱的前端逻辑”拆成可验证的参数还原流程;
  • 如何把结果固化成一个可运行的还原脚本。

这类问题我踩过不少坑,最大的教训是:别一上来就钻混淆代码,先把请求结构拆清楚,再决定从哪里切进去。


背景架构:一个典型前端签名链路

前端签名逻辑通常不是单点,而是一条链。先看一个抽象结构:

flowchart LR
    A[业务参数] --> B[参数标准化/排序]
    C[时间戳] --> B
    D[随机数 nonce] --> B
    E[Cookie/Token] --> B
    B --> F[拼接原文]
    F --> G[摘要算法 md5/sha256]
    G --> H[二次编码 hex/base64]
    H --> I[附加到请求头或请求体]
    I --> J[服务端验签]

再看浏览器端发起请求时更细一点的时序:

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名模块
    participant X as XHR/Fetch
    participant B as 服务端

    U->>P: 点击查询/翻页
    P->>P: 组装业务参数
    P->>S: 计算 sign/token/cipher
    S-->>P: 返回签名结果
    P->>X: 发起请求
    X->>B: 发送 headers/body/query
    B-->>X: 验签并返回响应
    X-->>P: 页面渲染

这个视角很重要。因为你真正要还原的,通常不是某个哈希函数,而是整条链上的几个关键节点:

  • 原始入参是什么;
  • 入参在签名前有没有被排序、裁剪、编码;
  • 签名放在 query、body 还是 header;
  • 签名前后是否还有加密、压缩、序列化;
  • 依赖哪些运行时上下文。

核心原理

1. 请求重放失败,先判断是哪一层出问题

一个接口失败,不一定就是签名错了。经验上先分层判断:

第一层:传输层是否一致

检查:

  • 请求方法:GET/POST/PUT
  • Content-Type
  • Origin / Referer
  • Cookie 是否完整
  • 是否依赖某些 Header,如 X-Requested-WithAuthorization

如果这些都不一致,服务端可能直接拒绝,和签名算法无关。

第二层:参数结构是否一致

重点看:

  • query 参数顺序是否敏感;
  • body 是 JSON、Form 还是 protobuf;
  • 空值字段是否参与签名;
  • 数字与字符串是否区分;
  • 对象字段是否排序。

很多人复制了“看起来相同”的 JSON,但实际:

  • 浏览器发送的是压缩后的字符串;
  • 前端对字段做了 JSON.stringify
  • 某些 key 为空时被过滤掉;
  • 对象按字典序排序后再签名。

第三层:签名值是否依赖动态环境

常见依赖项:

  • 当前时间戳
  • 随机数 nonce
  • 用户 token / session
  • 本地存储中的设备指纹
  • 某个初始化接口返回的临时密钥
  • 浏览器环境特征,如 navigator.userAgent

如果你只复制静态请求,重放时这部分往往已经失效。


2. 从“算法”转向“数据流”定位

Web 逆向里,真正高效的不是先问“它用了 AES 还是 RSA”,而是先问:

sign 这个值在发送前最后一次被赋值的地方在哪里?

也就是说,要沿着数据流查,而不是只盯着算法名。

推荐一个实用的定位顺序:

  1. 抓包确认最终请求
  2. 全局搜索参数名
  3. Hook 请求发送点
  4. Hook 常见加密函数
  5. 看调用栈回溯到业务层
  6. 最小化还原输入输出

这个思路的关键,是让问题不断收敛:

  • 从“整个站点”收敛到“某个请求”
  • 从“所有脚本”收敛到“某个调用栈”
  • 从“整个模块”收敛到“一个签名前原文”

3. 常见签名与加密模式

在中级实战里,最常见的是这几类:

模式 A:明文拼接 + Hash

例如:

sign = md5(path + timestamp + nonce + secret + JSON.stringify(data))

特点:

  • 最常见
  • 容易在前端完全还原
  • 关键是找到拼接顺序和 secret 来源

模式 B:参数排序 + QueryString 哈希

例如:

a=1&b=2&c=3 -> sha256(...)

特点:

  • 字段顺序敏感
  • 空值处理很关键
  • 经常和 URL 编码顺序纠缠在一起

模式 C:先加密数据,再对密文签名

例如:

cipher = AES(data, key, iv)
sign = md5(cipher + timestamp)

特点:

  • 需要区分“加密字段”和“签名字段”
  • 很多人只还原 sign,却忽略 data 本身也变了

模式 D:服务端下发动态种子

例如:

  • 页面初始化接口返回 salt
  • JS 拿 salt + body + ts 生成签名

特点:

  • 单看静态 JS 很难完整还原
  • 必须把上下文初始化流程一起复刻

方案对比与取舍分析

架构类问题不能只讲“怎么做”,还得讲“为什么这么做”。
前端签名定位常见有三条路,各有适用边界。

路线一:纯抓包重放

优点

  • 上手快
  • 不需要读源码
  • 适合一次性验证

缺点

  • 业务参数一变就失效
  • 无法适应动态 timestamp/nonce
  • 无法规模化自动化

适用场景

  • 快速判断接口是否存在签名保护
  • 验证是否有简单时效限制

路线二:浏览器内 Hook + 运行时还原

优点

  • 最容易拿到真实输入输出
  • 可以绕开混淆阅读成本
  • 对 webpack/混淆代码很友好

缺点

  • 依赖浏览器环境
  • 自动化程度一般
  • 页面刷新后需要重新布置 Hook

适用场景

  • 定位签名入口
  • 观察加密前原文
  • 追调用栈

路线三:离线抽取算法 + 独立脚本复刻

优点

  • 可批量化
  • 易集成到爬虫或测试链路
  • 最终维护成本低

缺点

  • 前期分析成本高
  • 容易被环境依赖卡住
  • 遇到 wasm/动态密钥会更复杂

适用场景

  • 稳定批量请求
  • 长期维护
  • 需要脱离浏览器运行

推荐取舍

我个人更推荐这个组合:

  1. 先抓包重放,确认失败点;
  2. 再用 Hook 找到签名入口
  3. 最后抽离成离线脚本

也就是:验证 -> 定位 -> 固化
这个流程比一上来啃混淆代码,成功率高很多。


实战代码(可运行)

下面用一个最小可运行示例,把“请求重放到参数还原”的过程走通。
我们假设目标站点的逻辑是:

  • 请求体是 JSON
  • 前端会对业务参数按 key 排序
  • 拼接 path + ts + nonce + body + secret
  • 使用 sha256 生成 sign

说明:示例用于演示定位与还原思路,请在合法授权范围内开展研究与测试。


1. 前端页面中的示例签名逻辑

先构造一个浏览器端示例,方便理解目标长什么样。

function stableStringify(obj) {
  if (obj === null || typeof obj !== 'object') {
    return JSON.stringify(obj);
  }
  if (Array.isArray(obj)) {
    return '[' + obj.map(stableStringify).join(',') + ']';
  }
  const keys = Object.keys(obj).sort();
  return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
}

async function signRequest(path, data, token) {
  const ts = Date.now().toString();
  const nonce = Math.random().toString(16).slice(2, 10);
  const secret = 'demo_secret_' + token.slice(0, 6);
  const body = stableStringify(data);
  const raw = [path, ts, nonce, body, secret].join('|');

  const buf = new TextEncoder().encode(raw);
  const digest = await crypto.subtle.digest('SHA-256', buf);
  const sign = Array.from(new Uint8Array(digest))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  return { ts, nonce, sign, body };
}

这个函数在真实场景里可能被压缩成一坨,但核心元素往往还是这些:

  • path
  • data
  • ts
  • nonce
  • secret
  • hash

2. 浏览器中 Hook fetch / XHR,捕获签名前后数据

这一步很有用。你不一定马上知道签名函数在哪里,但你可以先拿到发送时的最终参数。

Hook fetch

(function () {
  const rawFetch = window.fetch;
  window.fetch = async function (...args) {
    const [url, options = {}] = args;

    console.log('=== fetch request ===');
    console.log('url:', url);
    console.log('method:', options.method || 'GET');
    console.log('headers:', options.headers || {});
    console.log('body:', options.body || null);
    console.trace('fetch stack');

    const resp = await rawFetch.apply(this, args);
    return resp;
  };
})();

Hook XHR

(function () {
  const rawOpen = XMLHttpRequest.prototype.open;
  const rawSend = XMLHttpRequest.prototype.send;
  const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._method = method;
    this._url = url;
    this._headers = {};
    return rawOpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
    this._headers[key] = value;
    return rawSetRequestHeader.call(this, key, value);
  };

  XMLHttpRequest.prototype.send = function (body) {
    console.log('=== xhr request ===');
    console.log('url:', this._url);
    console.log('method:', this._method);
    console.log('headers:', this._headers);
    console.log('body:', body);
    console.trace('xhr stack');
    return rawSend.call(this, body);
  };
})();

通过这两段代码,你可以快速确认:

  • sign 在 header 还是 body
  • body 是对象字符串还是密文
  • 哪个调用栈触发了请求

这一步通常能直接把排查范围缩小 80%。


3. Hook 常见摘要函数,逼近签名原文

如果页面用了 crypto.subtle.digestmd5sha256 之类的方法,Hook 算法调用点是非常有效的。

Hook Web Crypto

(function () {
  const rawDigest = crypto.subtle.digest.bind(crypto.subtle);

  crypto.subtle.digest = async function (algorithm, data) {
    let text = '';
    try {
      text = new TextDecoder().decode(data);
    } catch (e) {
      text = '[binary data]';
    }

    console.log('=== digest called ===');
    console.log('algorithm:', algorithm);
    console.log('input:', text);
    console.trace('digest stack');

    const result = await rawDigest(algorithm, data);
    const hex = Array.from(new Uint8Array(result))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
    console.log('output hex:', hex);

    return result;
  };
})();

这段 Hook 的价值在于:
你看到的不是“算法名”,而是参与摘要的原始字符串

如果日志里出现类似:

/api/search|1700000000000|8f2a1c3d|{"page":1,"q":"phone"}|demo_secret_ab12cd

那还原基本就只剩体力活了。


4. 用 Node.js 独立复刻签名逻辑

当你已经确认了原文拼接规则,就可以把逻辑抽离成可独立运行脚本。

// sign.js
const crypto = require('crypto');

function stableStringify(obj) {
  if (obj === null || typeof obj !== 'object') {
    return JSON.stringify(obj);
  }
  if (Array.isArray(obj)) {
    return '[' + obj.map(stableStringify).join(',') + ']';
  }
  const keys = Object.keys(obj).sort();
  return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(obj[k])).join(',') + '}';
}

function buildSign({ path, data, token, ts, nonce }) {
  const secret = 'demo_secret_' + token.slice(0, 6);
  const body = stableStringify(data);
  const raw = [path, ts, nonce, body, secret].join('|');
  const sign = crypto.createHash('sha256').update(raw).digest('hex');
  return { sign, body, raw };
}

function demo() {
  const input = {
    path: '/api/search',
    data: { q: 'phone', page: 1 },
    token: 'ab12cd34ef56',
    ts: '1700000000000',
    nonce: '8f2a1c3d'
  };

  const result = buildSign(input);
  console.log('raw:', result.raw);
  console.log('body:', result.body);
  console.log('sign:', result.sign);
}

if (require.main === module) {
  demo();
}

module.exports = { buildSign };

运行:

node sign.js

5. 发起请求重放

接着用 Python 或 Node 发送请求。这里用 Python,便于快速验证。

# replay.py
import hashlib
import json
import requests

def stable_dumps(obj):
    if isinstance(obj, dict):
        items = []
        for k in sorted(obj.keys()):
            items.append(json.dumps(k, ensure_ascii=False) + ':' + stable_dumps(obj[k]))
        return '{' + ','.join(items) + '}'
    elif isinstance(obj, list):
        return '[' + ','.join(stable_dumps(x) for x in obj) + ']'
    else:
        return json.dumps(obj, ensure_ascii=False, separators=(',', ':'))

def build_sign(path, data, token, ts, nonce):
    secret = 'demo_secret_' + token[:6]
    body = stable_dumps(data)
    raw = '|'.join([path, ts, nonce, body, secret])
    sign = hashlib.sha256(raw.encode('utf-8')).hexdigest()
    return sign, body, raw

def main():
    path = '/api/search'
    url = 'https://example.com' + path
    token = 'ab12cd34ef56'
    ts = '1700000000000'
    nonce = '8f2a1c3d'
    data = {'q': 'phone', 'page': 1}

    sign, body, raw = build_sign(path, data, token, ts, nonce)

    headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + token,
        'X-Ts': ts,
        'X-Nonce': nonce,
        'X-Sign': sign,
    }

    print('raw =', raw)
    print('sign =', sign)

    resp = requests.post(url, data=body.encode('utf-8'), headers=headers, timeout=10)
    print(resp.status_code)
    print(resp.text)

if __name__ == '__main__':
    main()

为什么这个示例“够像真实环境”

因为它覆盖了实战里最容易出错的几个点:

  • 自定义 JSON 序列化
  • header 签名
  • token 派生 secret
  • 时间戳与随机数参与计算
  • 业务 body 与签名 body 必须完全一致

参数还原的系统定位方法

上面是最小示例。真实项目里,建议按下面这条定位路径走。

flowchart TD
    A[抓包得到目标请求] --> B{直接重放成功?}
    B -- 是 --> C[优先做参数自动化]
    B -- 否 --> D[对比 headers/body/query/cookie]
    D --> E{存在动态参数?}
    E -- 否 --> F[检查序列化与编码方式]
    E -- 是 --> G[Hook fetch/xhr]
    G --> H[定位 sign/cipher 最终赋值]
    H --> I[Hook digest/encrypt 函数]
    I --> J[拿到签名前原文]
    J --> K[抽离独立脚本复刻]
    K --> L[再次重放验证]

这条路径背后的原则很简单:

  • 先确认现象
  • 再抓关键中间态
  • 最后固化算法

不要跳步骤。尤其别在还没确认 body 是否一致时,就开始追 5000 行混淆代码。


常见坑与排查

这一节很关键。很多“签名不对”的问题,根本不是算法错,而是细节偏差。

1. JSON 序列化不一致

典型现象

浏览器请求成功,脚本请求失败,签名也对不上。

常见原因

  • Python 默认 json.dumps 有空格
  • key 顺序不一致
  • true/false/null 表达不同
  • 中文是否转义不同

排查建议

把这三份内容打印出来逐个比:

  1. 浏览器发送的 body
  2. 参与签名的原文
  3. 你脚本里实际发送的 body

很多时候,第 3 项和第 2 项根本不是同一个字符串。


2. Header 参与签名但你没注意到

有些站点会把这些信息一并纳入签名:

  • User-Agent
  • Origin
  • X-Client-Id
  • Authorization
  • Cookie 中某个字段

排查方法

看签名前原文里是否包含这些字段;
如果找不到,就在请求发送前 Hook 整个 options/header 对象。


3. 时间戳不是“当前时间”

我当时踩过一个坑:页面里虽然写了 Date.now(),但实际发请求用的是服务端时间偏移矫正后的值

典型模式

const ts = Date.now() + window.__server_time_offset__;

排查建议

  • 看初始化接口有没有返回 server time
  • 看本地是否缓存了时间偏移
  • 比较浏览器中的时间戳与本机生成值是否存在固定差值

4. nonce 看似随机,实则有规则

有些 nonce 不是纯随机,而是:

  • 时间戳截取
  • 设备 ID + 随机数
  • 自增计数器
  • 某个种子经过哈希

如果服务端会校验 nonce 格式,随便生成一个可能直接失败。


5. 混淆代码里搜索不到 sign

这很正常。因为变量名可能早就变成:

  • a
  • _0x1ab3c
  • n.default
  • r["xY"]

更有效的方式

不要搜 sign,优先搜:

  • 接口路径
  • 固定 header 名
  • digest
  • encrypt
  • setRequestHeader
  • JSON.stringify
  • Date.now
  • Math.random

或者直接从 Hook 打出的调用栈反查模块。


6. 加密后的 data 才是真正请求体

有些接口是这样:

{
  "data": "U2FsdGVkX1...",
  "sign": "abc123",
  "ts": "1700000000000"
}

你如果只还原 sign,但 data 还是旧值,服务端一样会失败。

排查思路

先判断:

  • 签名针对的是明文还是密文?
  • body 中的 data 本身有没有二次加工?
  • sign 是否依赖 data 的密文结果?

安全/性能最佳实践

这部分既是逆向时的操作建议,也是做系统分析时的边界意识。

1. 优先记录中间态,而不是反复全量跑页面

在浏览器里反复点页面、打断点、重载,很耗时间。
更推荐的做法是:

  • Hook 后把关键参数打印出来
  • 固化成最小复现输入
  • 离线跑签名逻辑

这样可以把“页面依赖”快速降到最低。


2. 做最小化抽取,别把整站代码都搬到 Node 里

很多人第一次抽离算法时,会想把 webpack 打包后的整个 bundle 搬进 Node。
这通常又重又脆。

更好的方式是只抽:

  • 参数标准化函数
  • 核心签名函数
  • 必要的环境垫片

如果只缺少少量浏览器对象,可以手动补:

global.window = {};
global.navigator = { userAgent: 'Mozilla/5.0 demo' };
global.location = { href: 'https://example.com/' };

3. 对 Hook 做限流和筛选

如果站点请求很多,全局 Hook 会刷爆控制台,影响性能。

建议加过滤条件:

if (typeof url === 'string' && url.includes('/api/search')) {
  console.log('target request:', url);
}

对摘要函数也一样,只打印长度、关键前缀或指定调用栈。


4. 保留“输入-原文-签名-请求体”四元组

长期维护时,这四个东西最好都落盘:

  • 输入参数
  • 签名前原文
  • 最终签名
  • 实际发送 body

这是后续排障最有价值的证据链。
一旦站点升级,你可以立刻知道变化发生在:

  • 原文拼接
  • 序列化方式
  • secret 来源
  • 请求结构

5. 注意合法合规边界

Web 逆向本身是一项中立技术,但使用场景必须合法合规。建议仅在以下范围内操作:

  • 自有系统调试
  • 经授权的安全测试
  • 协议兼容性分析
  • 教学研究

不要跨越授权边界,不要绕过访问控制去获取无权访问的数据。


6. 容量与维护成本估算

如果你的目标是长期稳定调用某类接口,可以简单估一下维护成本:

方案初始成本运行成本稳定性适合长期维护
纯抓包重放
浏览器自动化执行原页面一般
抽离签名逻辑离线运行

如果接口调用量较大,离线抽取通常更划算。
因为浏览器自动化方案在并发、资源占用、环境漂移上都更重。


一套可执行的排查清单

如果你正在做一个真实目标,我建议按这个顺序逐项打勾:

  1. 抓到目标请求,导出完整 headers/query/body
  2. 原样重放一次,确认失败
  3. 对比 Cookie、Origin、Referer、Authorization
  4. 确认 body 序列化格式
  5. 标记所有动态字段:ts/nonce/sign/token/data
  6. Hook fetch/xhr
  7. Hook crypto.subtle.digest / 常见加密函数
  8. 记录签名前原文
  9. 还原 secret 来源
  10. 抽离最小脚本
  11. 用固定输入做浏览器与脚本结果对比
  12. 再进行业务参数变更测试

这套流程最大的好处是:
即使目标换了站点,方法仍然成立。


总结

从请求重放到参数还原,真正要解决的不是“某个 md5 算法怎么写”,而是:

  • 请求到底哪里变了;
  • 哪些字段是动态生成的;
  • 签名前的原文是什么;
  • 原文又依赖哪些运行时上下文。

你可以把整个过程理解成三步:

  1. 验证请求结构:确认不是 headers、Cookie、序列化的问题;
  2. 定位数据流入口:通过 Hook 请求与摘要函数,抓住签名前中间态;
  3. 抽离独立实现:把浏览器中的逻辑最小化搬到离线脚本中。

如果只能记住一句话,我建议记这个:

Web 逆向里,先还原“数据如何流动”,再还原“算法如何计算”。

这样做,你面对的就不是一团混淆代码,而是一条可验证、可拆解、可复刻的链路。
而这,才是中级阶段真正需要建立起来的方法感。


分享到:

上一篇
《Spring Boot 中基于 Redis 与 AOP 实现接口幂等控制的实战指南》
下一篇
《分布式架构中基于 Saga 模式的跨服务事务设计与落地实践》