背景与问题
做 Web 逆向时,最常见的场景不是“看不懂代码”,而是“请求明明长得差不多,重放就是失败”。
很多接口表面上只是多了几个参数,比如:
signtokentnoncecipherdata
但真正麻烦的是:这些字段往往不是固定值,而是由前端脚本基于时间戳、随机数、请求体、设备信息、Cookie、局部状态共同计算出来的。
于是问题会变成这样:
- 抓到了接口请求,直接在 Postman 或脚本里重放,失败;
- 修改少量业务参数后,请求返回“签名错误”;
- 页面代码经过压缩混淆,搜索
sign找不到有效线索; - 就算知道用了
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-TypeOrigin/Referer- Cookie 是否完整
- 是否依赖某些 Header,如
X-Requested-With、Authorization
如果这些都不一致,服务端可能直接拒绝,和签名算法无关。
第二层:参数结构是否一致
重点看:
- query 参数顺序是否敏感;
- body 是 JSON、Form 还是 protobuf;
- 空值字段是否参与签名;
- 数字与字符串是否区分;
- 对象字段是否排序。
很多人复制了“看起来相同”的 JSON,但实际:
- 浏览器发送的是压缩后的字符串;
- 前端对字段做了
JSON.stringify; - 某些 key 为空时被过滤掉;
- 对象按字典序排序后再签名。
第三层:签名值是否依赖动态环境
常见依赖项:
- 当前时间戳
- 随机数
nonce - 用户 token / session
- 本地存储中的设备指纹
- 某个初始化接口返回的临时密钥
- 浏览器环境特征,如
navigator.userAgent
如果你只复制静态请求,重放时这部分往往已经失效。
2. 从“算法”转向“数据流”定位
Web 逆向里,真正高效的不是先问“它用了 AES 还是 RSA”,而是先问:
sign这个值在发送前最后一次被赋值的地方在哪里?
也就是说,要沿着数据流查,而不是只盯着算法名。
推荐一个实用的定位顺序:
- 抓包确认最终请求
- 全局搜索参数名
- Hook 请求发送点
- Hook 常见加密函数
- 看调用栈回溯到业务层
- 最小化还原输入输出
这个思路的关键,是让问题不断收敛:
- 从“整个站点”收敛到“某个请求”
- 从“所有脚本”收敛到“某个调用栈”
- 从“整个模块”收敛到“一个签名前原文”
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/动态密钥会更复杂
适用场景
- 稳定批量请求
- 长期维护
- 需要脱离浏览器运行
推荐取舍
我个人更推荐这个组合:
- 先抓包重放,确认失败点;
- 再用 Hook 找到签名入口;
- 最后抽离成离线脚本。
也就是:验证 -> 定位 -> 固化。
这个流程比一上来啃混淆代码,成功率高很多。
实战代码(可运行)
下面用一个最小可运行示例,把“请求重放到参数还原”的过程走通。
我们假设目标站点的逻辑是:
- 请求体是 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.digest、md5、sha256 之类的方法,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表达不同- 中文是否转义不同
排查建议
把这三份内容打印出来逐个比:
- 浏览器发送的 body
- 参与签名的原文
- 你脚本里实际发送的 body
很多时候,第 3 项和第 2 项根本不是同一个字符串。
2. Header 参与签名但你没注意到
有些站点会把这些信息一并纳入签名:
User-AgentOriginX-Client-IdAuthorizationCookie中某个字段
排查方法
看签名前原文里是否包含这些字段;
如果找不到,就在请求发送前 Hook 整个 options/header 对象。
3. 时间戳不是“当前时间”
我当时踩过一个坑:页面里虽然写了 Date.now(),但实际发请求用的是服务端时间偏移矫正后的值。
典型模式
const ts = Date.now() + window.__server_time_offset__;
排查建议
- 看初始化接口有没有返回 server time
- 看本地是否缓存了时间偏移
- 比较浏览器中的时间戳与本机生成值是否存在固定差值
4. nonce 看似随机,实则有规则
有些 nonce 不是纯随机,而是:
- 时间戳截取
- 设备 ID + 随机数
- 自增计数器
- 某个种子经过哈希
如果服务端会校验 nonce 格式,随便生成一个可能直接失败。
5. 混淆代码里搜索不到 sign
这很正常。因为变量名可能早就变成:
a_0x1ab3cn.defaultr["xY"]
更有效的方式
不要搜 sign,优先搜:
- 接口路径
- 固定 header 名
digestencryptsetRequestHeaderJSON.stringifyDate.nowMath.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. 容量与维护成本估算
如果你的目标是长期稳定调用某类接口,可以简单估一下维护成本:
| 方案 | 初始成本 | 运行成本 | 稳定性 | 适合长期维护 |
|---|---|---|---|---|
| 纯抓包重放 | 低 | 低 | 低 | 否 |
| 浏览器自动化执行原页面 | 中 | 高 | 中 | 一般 |
| 抽离签名逻辑离线运行 | 高 | 低 | 高 | 是 |
如果接口调用量较大,离线抽取通常更划算。
因为浏览器自动化方案在并发、资源占用、环境漂移上都更重。
一套可执行的排查清单
如果你正在做一个真实目标,我建议按这个顺序逐项打勾:
- 抓到目标请求,导出完整 headers/query/body
- 原样重放一次,确认失败
- 对比 Cookie、Origin、Referer、Authorization
- 确认 body 序列化格式
- 标记所有动态字段:
ts/nonce/sign/token/data - Hook
fetch/xhr - Hook
crypto.subtle.digest/ 常见加密函数 - 记录签名前原文
- 还原 secret 来源
- 抽离最小脚本
- 用固定输入做浏览器与脚本结果对比
- 再进行业务参数变更测试
这套流程最大的好处是:
即使目标换了站点,方法仍然成立。
总结
从请求重放到参数还原,真正要解决的不是“某个 md5 算法怎么写”,而是:
- 请求到底哪里变了;
- 哪些字段是动态生成的;
- 签名前的原文是什么;
- 原文又依赖哪些运行时上下文。
你可以把整个过程理解成三步:
- 验证请求结构:确认不是 headers、Cookie、序列化的问题;
- 定位数据流入口:通过 Hook 请求与摘要函数,抓住签名前中间态;
- 抽离独立实现:把浏览器中的逻辑最小化搬到离线脚本中。
如果只能记住一句话,我建议记这个:
Web 逆向里,先还原“数据如何流动”,再还原“算法如何计算”。
这样做,你面对的就不是一团混淆代码,而是一条可验证、可拆解、可复刻的链路。
而这,才是中级阶段真正需要建立起来的方法感。