从抓包到算法还原:中级开发者实战 Web 逆向中的签名参数分析与自动化复现
很多中级开发者第一次接触 Web 逆向时,都会有一种“明明请求我都看见了,但就是复现不出来”的挫败感。
接口地址、请求头、请求体都抄下来了,浏览器里请求也成功了,可一旦换成脚本,就返回:
sign invalidtimestamp expiredrequest forbiddenillegal token
问题往往不在“接口没找到”,而在于签名参数没有还原。
这篇文章我不走空泛路线,而是带你从一个中级开发者能真正上手的角度,把完整流程走一遍:
- 抓包定位关键请求
- 判断签名字段
- 追踪 JavaScript 代码
- 还原算法
- 用脚本自动化复现
- 排查常见失败原因
说明:本文讨论的是授权测试、接口联调、兼容性分析、内部调试等合法场景下的技术方法。不要将其用于未授权目标。
背景与问题
现代 Web 应用为了防止接口被随意调用,通常会在前端生成一组动态参数,例如:
signsignaturetokentsnoncex-sx-signencryptData
这些参数的生成可能依赖:
- 时间戳
- 随机数
- 请求路径
- 请求体字段排序
- cookie/localStorage 中的值
- 浏览器环境指纹
- 某段混淆后的 JS 算法
所以实际问题不是“怎么发请求”,而是:
怎么找到签名生成逻辑,并把它稳定地在脚本里复现出来。
这类问题最典型的难点有三个:
- 参数来源分散:请求头、cookie、body、内存变量混着来
- 代码被混淆:函数名不可读、逻辑碎片化
- 环境耦合:浏览器有
window/document/navigator,Node/Python 没有
前置知识与环境准备
建议你具备以下基础:
- 会用 Chrome DevTools 抓包
- 能读懂基础 JavaScript
- 知道 MD5 / SHA1 / SHA256 / HMAC 的基本区别
- 会用 Python 或 Node.js 发 HTTP 请求
本文示例环境:
- Chrome DevTools
- Node.js 18+
- Python 3.10+
- 可选:
mitmproxy/Charles/Fiddler
安装依赖:
npm install crypto-js axios
pip install requests
逐步验证清单
在真正写自动化脚本前,我建议你按下面这个清单一项项过:
- 请求路径、方法、Query 参数完全一致
- 请求头是否缺少关键字段
- cookie 是否有效
- 时间戳单位是秒还是毫秒
- 签名前的参数是否排序
- JSON 序列化是否和浏览器一致
- 是否参与签名的字段比你想象得多
- 签名是否依赖 localStorage/sessionStorage
- 是否依赖浏览器环境值
- 签名结果是否做了二次编码(hex/base64/urlencode)
这个清单看起来基础,但我自己踩坑时,80% 的问题都卡在这里。
核心原理
先把核心思路说透:签名参数本质上是“服务端与前端约定的一种可验证计算结果”。
一个常见签名过程可能是这样:
- 收集参与签名的字段
- 按固定顺序拼接
- 拼上一个 secret 或盐值
- 做哈希运算
- 输出 hex 或 base64
- 放入 header 或 query 中
例如:
sign = md5(path + ts + nonce + body + secret)
或者:
sign = sha256(sortedQuery + "|" + token + "|" + ts)
再复杂一点,会有:
- AES 加密后再做摘要
- HMAC 替代普通哈希
- 先 JSON 序列化再编码
- 参数名参与拼接
- 只取哈希结果的一部分
- 字符串反转、异或、字符表替换
一个典型分析流程
flowchart TD
A[抓包定位目标请求] --> B[识别可疑签名字段]
B --> C[全局搜索字段名]
C --> D[定位生成函数]
D --> E[梳理输入参数来源]
E --> F[还原拼接规则/加密算法]
F --> G[在 Node/Python 中复现]
G --> H[与浏览器结果对比验证]
请求时序关系
sequenceDiagram
participant U as 用户操作
participant B as 浏览器前端
participant J as 签名JS逻辑
participant S as 服务端
U->>B: 触发页面请求
B->>J: 收集 path/body/ts/token
J->>J: 生成 sign
J->>S: 发送带 sign 的请求
S->>S: 按同规则验签
S-->>B: 返回数据
背景示例:构造一个可分析的签名接口
为了让过程完整,这里我构造一个常见但不过分简单的签名规则:
- 请求路径:
/api/v1/list - 请求方法:
POST - body:
{
"page": 1,
"size": 20,
"keyword": "phone"
}
- header 中有:
x-ts: 当前毫秒时间戳x-nonce: 随机字符串x-sign: 签名值
签名规则假设为:
raw = method.toUpperCase() + "\n" +
path + "\n" +
canonical_json(body) + "\n" +
ts + "\n" +
nonce + "\n" +
secret
x-sign = sha256(raw).hex()
这里最关键的是 canonical_json(body),也就是稳定 JSON 序列化。
很多人复现失败,不是哈希算法错了,而是序列化结果不同。
实战代码(可运行)
下面用一个完整可运行的例子来演示算法复现。
第一步:在前端中识别签名逻辑
假设你在 DevTools 的 Network 面板里发现请求头中有:
x-ts: 1720000000123
x-nonce: ab12cd34
x-sign: 9d0f...
此时可以做几件事:
- 在
Sources全局搜索x-sign - 搜索
sha256/md5/CryptoJS - 搜索请求发起位置,比如
fetch(/api/v1/list) - 在 XHR/Fetch Breakpoints 中对接口路径下断点
很多时候你会看到类似代码:
function buildSign(method, path, body, ts, nonce) {
const secret = "demo_secret_2024";
const raw =
method.toUpperCase() + "\n" +
path + "\n" +
stableStringify(body) + "\n" +
ts + "\n" +
nonce + "\n" +
secret;
return sha256(raw);
}
如果源码没这么友好,而是混淆后的:
function _0x12ab(a,b,c,d,e){return _0x9f8e(a+'\n'+b+'\n'+_0x8c7d(c)+'\n'+d+'\n'+e+'\n'+_0x4b11);}
也别慌,拆出来仍然是同一件事:拼接字符串 + 摘要算法。
第二步:还原稳定 JSON 序列化
这里先用 Node.js 写一个“稳定序列化”函数,保证对象 key 按字典序输出。
function stableStringify(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(value).sort();
const items = keys.map(key => {
return JSON.stringify(key) + ':' + stableStringify(value[key]);
});
return '{' + items.join(',') + '}';
}
为什么这一步重要?
因为浏览器里可能签的是:
{"keyword":"phone","page":1,"size":20}
而你脚本里传出去的是:
{"page":1,"size":20,"keyword":"phone"}
语义一样,签名却完全不同。
第三步:Node.js 复现签名算法
const crypto = require('crypto');
const axios = require('axios');
function stableStringify(value) {
if (value === null || typeof value !== 'object') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(value).sort();
const items = keys.map(key => {
return JSON.stringify(key) + ':' + stableStringify(value[key]);
});
return '{' + items.join(',') + '}';
}
function sha256Hex(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function randomNonce(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function buildSign({ method, path, body, ts, nonce, secret }) {
const raw = [
method.toUpperCase(),
path,
stableStringify(body),
String(ts),
nonce,
secret
].join('\n');
return sha256Hex(raw);
}
async function main() {
const method = 'POST';
const path = '/api/v1/list';
const body = {
page: 1,
size: 20,
keyword: 'phone'
};
const ts = Date.now();
const nonce = randomNonce();
const secret = 'demo_secret_2024';
const sign = buildSign({
method,
path,
body,
ts,
nonce,
secret
});
console.log('ts =', ts);
console.log('nonce =', nonce);
console.log('sign =', sign);
const url = 'https://example.com' + path;
try {
const resp = await axios.post(url, body, {
headers: {
'content-type': 'application/json',
'x-ts': String(ts),
'x-nonce': nonce,
'x-sign': sign
},
timeout: 10000
});
console.log(resp.data);
} catch (err) {
if (err.response) {
console.log('status:', err.response.status);
console.log('data:', err.response.data);
} else {
console.error(err.message);
}
}
}
main();
第四步:Python 自动化复现版本
如果你的自动化链路主要在 Python,也可以直接搬过去:
import json
import time
import random
import string
import hashlib
import requests
def stable_dumps(value):
if value is None or not isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'))
if isinstance(value, list):
return '[' + ','.join(stable_dumps(item) for item in value) + ']'
items = []
for key in sorted(value.keys()):
k = json.dumps(key, ensure_ascii=False, separators=(',', ':'))
v = stable_dumps(value[key])
items.append(f'{k}:{v}')
return '{' + ','.join(items) + '}'
def sha256_hex(text: str) -> str:
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def random_nonce(length=8):
chars = string.ascii_lowercase + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def build_sign(method, path, body, ts, nonce, secret):
raw = '\n'.join([
method.upper(),
path,
stable_dumps(body),
str(ts),
nonce,
secret
])
return sha256_hex(raw)
def main():
method = 'POST'
path = '/api/v1/list'
url = 'https://example.com' + path
body = {
'page': 1,
'size': 20,
'keyword': 'phone'
}
ts = int(time.time() * 1000)
nonce = random_nonce()
secret = 'demo_secret_2024'
sign = build_sign(method, path, body, ts, nonce, secret)
headers = {
'content-type': 'application/json',
'x-ts': str(ts),
'x-nonce': nonce,
'x-sign': sign
}
print('ts =', ts)
print('nonce =', nonce)
print('sign =', sign)
resp = requests.post(url, json=body, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
if __name__ == '__main__':
main()
第五步:怎么验证你还原得对不对
真正高效的做法,不是直接怼接口,而是先做本地对照验证。
验证方法 1:浏览器 Console 里打印原始拼接串
如果能改写前端代码或在断点处执行表达式,可以打印:
console.log(raw);
console.log(sign);
然后和你 Node/Python 中生成的值比对。
验证方法 2:固定输入做单元测试
把时间戳和 nonce 固定住,避免动态值干扰:
const sample = {
method: 'POST',
path: '/api/v1/list',
body: { page: 1, size: 20, keyword: 'phone' },
ts: 1720000000123,
nonce: 'ab12cd34',
secret: 'demo_secret_2024'
};
这样浏览器和脚本应该输出完全相同的签名。
验证方法 3:比较“签名前字符串”,而不是只比较 sign
这是我最推荐的一步。
因为如果最终 sign 不一致,原因可能有十几个;但如果 raw 不一致,排查范围就瞬间缩小。
算法还原时的拆解方法
遇到真实场景,签名逻辑一般不会像示例这么整洁。这时建议按下面的路径拆。
1. 先找“出口”
也就是最终请求发出去的地方:
fetchXMLHttpRequest.sendaxios.interceptors.request.use
因为所有签名参数最终都要在这里汇合。
2. 再找“入口”
签名函数的输入通常来自:
location.pathname- 请求 body
- cookie
- localStorage
- 时间函数
Date.now() - 随机函数
Math.random()
3. 最后找“变换过程”
常见变换包括:
- 排序
- 编码
- JSON 序列化
- 哈希
- 加密
- 截断
- 拼接固定盐值
典型组件关系图
classDiagram
class RequestContext {
+method
+path
+query
+body
+headers
}
class ParamCollector {
+getTimestamp()
+getNonce()
+getToken()
}
class Canonicalizer {
+sortKeys()
+stableStringify()
+normalizePath()
}
class SignEngine {
+buildRaw()
+hash()
}
RequestContext --> Canonicalizer
ParamCollector --> SignEngine
Canonicalizer --> SignEngine
常见坑与排查
这部分非常重要。很多时候不是不会写代码,而是漏了一个看似不起眼的细节。
1. 时间戳单位错了
常见有两种:
- 秒:
1720000000 - 毫秒:
1720000000123
排查方法:
console.log(String(ts).length);
- 10 位通常是秒
- 13 位通常是毫秒
有些接口还要求:
- 时间不能偏差超过 5 秒
- 必须使用服务端下发的时间偏移量
2. body 参与签名,但你传的是另一份数据
例如签名使用的是:
{"keyword":"phone","page":1,"size":20}
但你实际发送时用了 requests.post(..., data=...) 或 form 格式,导致服务端收到的内容不一样。
排查建议:
- 确认
Content-Type - 确认发送的是
json还是form - 抓包对比真实出站请求
3. JSON 序列化不一致
几个典型差异:
- key 顺序不同
- 空格不同
- 中文是否转义
- 布尔值/空值格式
- 浮点数表示不同
例如 Python 默认 json.dumps 可能会输出空格,影响拼接结果,所以要显式加:
json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
4. 少了 cookie / token / localStorage 值
有些签名函数里会读:
localStorage.getItem('token')
document.cookie
你如果只复制了 header,没有同步这些上下文,签名一定不对。
排查方法:
- 在签名函数附近下断点
- Watch 关键变量
- 查调用栈看参数源头
5. 算法对路径做了标准化
例如实际签名用的不是完整 URL,而是:
- 只要 path,不要域名
- query 要排序
- path 末尾斜杠要去掉
- URL 编码前后不一致
错误示例:
https://example.com/api/v1/list?page=1
正确参与签名的可能是:
/api/v1/list
或:
/api/v1/list?page=1&size=20
6. 签名结果做了二次处理
你以为是 SHA256 hex,实际上可能是:
base64(sha256(raw))md5(raw).toUpperCase()sha1(raw).substr(8, 16)encodeURIComponent(base64(...))
排查时不要只盯着哈希函数本身,要看返回值离开函数前有没有再加工。
7. 混淆代码里“看起来像无关代码”的地方其实很关键
我曾经踩过一个坑:看着像无意义的函数,实际上是在做 key 排序和 unicode 归一化。删掉后,签名全错。
经验建议:
- 不要急着“简化”
- 先逐行验证输入输出
- 每删一层包裹函数,都要比对中间结果
安全/性能最佳实践
即使是做逆向分析,也建议把工程质量拉起来,不然脚本很脆。
1. 把签名逻辑做成纯函数
推荐这种形式:
function buildSign(ctx) {
// 输入固定,输出唯一
}
好处:
- 便于测试
- 便于迁移到不同运行时
- 便于定位问题
2. 固定测试样例,保存“黄金数据”
例如:
{
"method": "POST",
"path": "/api/v1/list",
"body": {"page":1,"size":20,"keyword":"phone"},
"ts": 1720000000123,
"nonce": "ab12cd34",
"sign": "预期结果"
}
每次改代码都跑一下,防止自己“优化”出 bug。
3. 尽量分离“算法复现”和“请求发送”
不要把逻辑都塞进一个脚本里。可以拆成:
sign.js/sign.pyclient.js/client.pytests/
这样后面接口变更时,只改一处。
4. 对动态环境依赖做适配层
如果签名代码依赖 window、document、navigator,建议做一层 shim:
global.window = {};
global.navigator = { userAgent: 'Mozilla/5.0' };
更复杂的场景可以用:
jsdomvm2- 直接在浏览器环境里执行 Playwright/Puppeteer 脚本
边界条件是:
如果算法深度依赖浏览器原生行为、Canvas、WebGL、指纹收集,纯 Node 复现成本会明显升高,这时更适合“借用浏览器执行环境”而不是硬抠。
5. 控制请求频率,避免无意义重试
签名失败时,有些人第一反应是疯狂重试。
这通常只会让问题更难看。
建议:
- 单次请求前先本地打印 raw 和 sign
- 对 401/403 分类处理
- 加指数退避
- 记录 request-id、时间戳、签名原文摘要
6. 妥善处理敏感信息
在调试输出中,不要直接打印:
- 完整 cookie
- token
- secret
- 用户隐私数据
推荐只打掩码日志:
function mask(text, keep = 4) {
if (!text || text.length <= keep * 2) return text;
return text.slice(0, keep) + '****' + text.slice(-keep);
}
从“能跑”到“稳定跑”:建议的工程化结构
对于中级开发者,我建议不要停留在 demo 层,而是直接按下面思路组织:
project/
├─ signer/
│ ├─ canonicalize.js
│ ├─ hash.js
│ └─ build-sign.js
├─ client/
│ └─ request.js
├─ fixtures/
│ └─ sign-case.json
└─ tests/
└─ sign.test.js
这样你后续遇到接口升级时,只需要回答三个问题:
- 输入参数有没有变
- 拼接顺序有没有变
- 摘要/编码方式有没有变
而不是每次重新抓瞎。
一个最小可用的调试模板
如果你现在手上就有一个签名接口要分析,可以先套这个模板:
function debugSignPipeline(ctx) {
const canonicalBody = stableStringify(ctx.body);
const raw = [
ctx.method.toUpperCase(),
ctx.path,
canonicalBody,
String(ctx.ts),
ctx.nonce,
ctx.secret
].join('\n');
const sign = sha256Hex(raw);
console.log('[debug] canonicalBody =', canonicalBody);
console.log('[debug] raw =', raw);
console.log('[debug] sign =', sign);
return sign;
}
这段代码的价值不在“高级”,而在于它能让你快速回答:
- 到底是哪一步不一致?
- 是 body 问题,还是 path 问题?
- 是 raw 不一致,还是 hash 不一致?
什么时候该换思路,而不是死磕算法还原
有些场景下,继续做纯算法还原性价比很低:
- 签名逻辑严重依赖浏览器指纹
- JS 动态下发、频繁变动
- WebAssembly 参与计算
- 关键逻辑在 native bridge 或插件中
- 服务端还做了设备态校验
这时更可行的方案通常是:
- 浏览器内执行:Puppeteer/Playwright 注入调用现成函数
- Hook 请求层:在页面环境中拦截并复用真实签名结果
- 半自动方案:把最难复现的部分留在浏览器中,其余逻辑脚本化
也就是说,别把“纯净还原”当成唯一目标。
工程上,稳定可维护往往比“理论最优雅”更重要。
总结
把 Web 逆向中的签名参数分析做明白,核心不是背多少加密算法,而是掌握一套稳定流程:
- 抓包定位目标请求
- 识别签名字段和依赖参数
- 全局搜索并定位生成函数
- 拆清楚输入、拼接、编码、哈希过程
- 先对比原始拼接串,再对比最终签名
- 最后再做自动化复现和工程化封装
如果你记不住全部细节,至少记住一句最有用的话:
签名失败时,先比对“签名前字符串”,不要只盯着最终 sign。
这是我自己在实战里最常用、也最省时间的方法。
最后给你几个可执行建议:
- 第一次分析时,优先固定
ts和nonce - 尽量把签名逻辑提炼成纯函数
- 为关键样例保存黄金测试数据
- 不要轻易忽略序列化、编码、排序这些“小细节”
- 如果环境依赖很重,及时切换到浏览器内执行方案
只要你把“抓包 → 定位 → 拆解 → 对照 → 复现”这条链路跑顺,绝大多数中等复杂度的 Web 签名参数,都是可以被稳定分析和自动化复现的。