从抓包到还原签名链路:一次典型 Web 逆向中前端加密参数定位与复现实战
做 Web 逆向时,最常见也最让人头疼的一类问题,不是“接口在哪”,而是“接口明明找到了,为什么我自己发就是 401 / 403 / 参数非法”。
很多时候,罪魁祸首并不是 Cookie,也不是 Header 少了,而是前端在发请求前做了一层或多层“签名处理”:时间戳、随机数、摘要、排序、AES、RSA、混淆封装……你抓包能看到结果,但不知道它怎么来的。
这篇文章我想用一个典型 Web 逆向流程,带你从抓包开始,一步步定位前端加密参数的生成逻辑,最后在本地复现出可运行代码。重点不是某个具体站点,而是一套可以迁移到大多数项目里的方法论。
说明:本文内容仅用于安全研究、接口联调、自动化测试与合规分析,请勿用于未授权的系统访问。
背景与问题
先描述一个很常见的场景。
你在浏览器里打开一个页面,点击“搜索”后,看到前端发出了一个 POST /api/search 请求。抓包后发现:
- 请求体里有业务参数,比如
keyword、page - Header 里多了几个奇怪字段,比如:
x-signx-tsx-nonce
- 服务端对这些值非常敏感,哪怕你只改一个字符,接口就直接报错
抓包工具中你能看到类似这样的请求:
POST /api/search HTTP/1.1
Host: example.com
Content-Type: application/json
x-ts: 1726440000123
x-nonce: 8f3c2d9b6a1e4c7f
x-sign: 1f2d43a8a0b4a0d3b2e4c89e1e4c77f2
{"keyword":"python","page":1,"pageSize":10}
这时问题就来了:
x-sign是怎么生成的?- 它依赖哪些字段?
- 是明文拼接后做 MD5/SHA,还是 AES/RSA?
- 参数顺序重要吗?
- 时间戳有没有时间窗口限制?
- 有没有设备指纹、环境校验、Hook 检测?
如果没有一条清晰的分析路径,很容易陷入“到处搜 md5(、乱打断点、越改越乱”的状态。我早期就经常这么干,最后调了半天,发现只是漏了一个固定盐值或者排序规则写错了。
所以这类题,最重要的是先建立一个分析框架。
前置知识
如果你准备跟着做,建议先具备这些基础:
- 会用 Chrome DevTools 看 Network / Sources / Console
- 知道抓包工具的基本使用方式
- 能看懂基本 JavaScript
- 对
MD5 / SHA256 / AES / RSA / HMAC有大致概念 - 会用 Node.js 跑一点辅助脚本
不需要你精通 AST、也不需要上来就会还原整套 webpack,只要能顺着调用链找函数,就够了。
环境准备
本文演示建议准备以下环境:
- Chrome 或 Edge
- Node.js 18+
- 一个抓包工具(Charles / Fiddler / mitmproxy 均可)
- 文本编辑器或 IDE
- 可选:
js-beautify、source-map-explorer、webpack-bundle-analyzer
初始化一个本地目录:
mkdir web-sign-replay
cd web-sign-replay
npm init -y
npm install crypto-js axios
如果你更偏向用原生 crypto,也可以不装 crypto-js。本文两种方式都会给。
逐步验证清单
在正式开搞之前,先给你一个我自己常用的验证清单。逆向前端签名时,务必按这个顺序排:
- 先确认哪个请求真正需要签名
- 先看签名在 Header、Query 还是 Body
- 记录时间戳、随机数、请求体原文
- 比较两次相同请求,找出变化字段
- 定位发请求入口:
fetch / XHR / axios - 向上追踪谁组装了 config / header
- 向下追踪签名函数输入与输出
- 验证是否存在参数排序 / JSON 序列化差异
- 验证是否包含固定盐值 / token / uid
- 验证是否有二次编码:
Base64、十六进制、URL 编码 - 验证是否有环境依赖:
navigator、window、localStorage - 最后再脱离浏览器做本地复现
这个顺序很重要。不要一上来就搜加密算法名,因为很多项目根本没直接暴露 md5、sha256 这些关键词,可能都被封装在工具函数里了。
核心原理
前端签名链路,本质上通常可以抽象成下面几步:
- 收集业务参数
- 生成动态参数,如时间戳、随机数
- 进行规范化处理
- 排序
- 序列化
- 字段裁剪
- 拼接固定盐值
- 执行摘要或加密
- 写入 Header / Query / Body
- 服务端按相同规则验证
典型签名链路
flowchart TD
A[用户操作] --> B[前端组装业务参数]
B --> C[生成动态参数 ts/nonce]
C --> D[参数规范化 排序/序列化]
D --> E[拼接固定盐值或 token]
E --> F[摘要或加密 MD5/SHA/AES/HMAC]
F --> G[写入 Header/Body/Query]
G --> H[服务端按同规则验签]
前端定位思路
逆向时最有效的入口,通常不是“加密函数”,而是“请求发出点”。
sequenceDiagram
participant U as 用户
participant P as 页面脚本
participant S as 签名函数
participant H as 请求封装器
participant A as 接口服务端
U->>P: 点击搜索
P->>H: 调用 api.search(data)
H->>S: 生成 ts/nonce/sign
S-->>H: 返回签名结果
H->>A: 发送带 Header 的请求
A-->>H: 验签通过后返回数据
H-->>P: 渲染列表
常见签名类型
中级实战里,最常见的是这几类:
| 类型 | 常见表现 | 逆向难度 |
|---|---|---|
| 纯摘要签名 | md5(ts + nonce + body + salt) | 低 |
| HMAC | HmacSHA256(message, key) | 中 |
| 对称加密 | Body 加密成密文,Header 有 sign | 中 |
| 非对称加密 | 只加密关键字段,常见登录场景 | 中高 |
| 混合链路 | AES + RSA + Sign + 混淆 | 高 |
本文主要以摘要签名 + 参数规范化这种最典型、最常见的链路做演示,因为你掌握这个套路后,再往上叠复杂度也有路可走。
背景案例建模:一个典型签名规则
为了让流程清晰,我们假设页面里真正的签名规则如下:
- 取请求体对象
body - 对 key 按字典序排序
- 转成紧凑 JSON 字符串
- 与
ts、nonce、固定盐值appSecret按固定格式拼接 - 做 MD5,输出小写 32 位十六进制
即:
sign = md5(ts + "|" + nonce + "|" + canonicalBody + "|" + appSecret)
业务请求最终形态:
{
"headers": {
"x-ts": "1726440000123",
"x-nonce": "8f3c2d9b6a1e4c7f",
"x-sign": "..."
},
"body": {
"keyword": "python",
"page": 1,
"pageSize": 10
}
}
这个模型很“典型”,因为它包含了你实战里最容易忽略的几个点:
- 排序
- JSON 序列化一致性
- 固定盐值
- 时间戳与随机数参与签名
实战:从抓包开始定位签名入口
第一步:抓包对比,找出变化项
先连续发两次几乎相同的请求,只改一个业务参数,或者完全不改。
对比你会发现:
x-ts每次变x-nonce每次变x-sign每次变- 业务 body 不变时,
sign也会随ts和nonce变化
这说明:
sign很可能依赖tssign很可能依赖noncesign未必只是 body 摘要
如果你再改一下 keyword,发现 sign 再次变化,就基本确定:签名依赖业务参数 + 动态参数。
第二步:在 DevTools 找发请求位置
打开浏览器开发者工具:
- 切到
Network - 找到目标请求
- 查看
Initiator - 跳到对应源码位置
如果项目用了 axios,你大概率会看到类似:
service.interceptors.request.use((config) => {
const ts = Date.now().toString();
const nonce = randomString(16);
const sign = makeSign(config.data, ts, nonce);
config.headers["x-ts"] = ts;
config.headers["x-nonce"] = nonce;
config.headers["x-sign"] = sign;
return config;
});
但现实往往没这么美好。更多情况是:
- 文件被 webpack 打包
- 变量名全是
n,r,o,_0xabc123 - 签名函数经过多层封装
这时别急,先抓住几个关键字符串搜索:
x-signx-tsx-nonce- 请求路径
/api/search setRequestHeaderinterceptors.request.usefetch(XMLHttpRequest.prototype.send
谁离请求最近,就先跟谁。
第三步:打断点看签名输入
当你定位到写 Header 的地方后,给这一行打断点,观察:
ts来源nonce来源sign是哪个函数算的sign的入参是什么
一个很常见的调用关系是:
const sign = uo(config.data, ts, nonce);
别被函数名吓到,继续点进去。
你真正要看的不是函数名,而是:
- 它是不是先
JSON.stringify - 有没有
sort - 有没有拼接固定字符串
- 有没有调
md5/sha256/CryptoJS
核心还原:参数规范化比算法更重要
很多人把注意力全放在“是什么加密算法”,但在纯前端签名里,真正容易出错的往往不是算法,而是签名前的数据长什么样。
比如下面这三种字符串,看起来都差不多,但摘要结果会完全不同:
{"keyword":"python","page":1,"pageSize":10}
{"page":1,"keyword":"python","pageSize":10}
{"keyword": "python", "page": 1, "pageSize": 10}
差异点包括:
- key 顺序不同
- 空格不同
- 数字与字符串类型不同
null/undefined字段是否参与- 布尔值是否转字符串
所以逆向时,先还原“规范化规则”,再还原算法,成功率会高很多。
实战代码(可运行)
下面我用一个完整 Node.js 示例,把这个典型签名流程复现出来。你可以直接运行。
版本一:使用 Node 原生 crypto
新建 sign-demo.js:
const crypto = require("crypto");
const axios = require("axios");
/**
* 生成随机 nonce
*/
function randomNonce(length = 16) {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
/**
* 对对象按 key 递归排序
*/
function sortObject(input) {
if (Array.isArray(input)) {
return input.map(sortObject);
}
if (input && typeof input === "object") {
const sorted = {};
Object.keys(input)
.sort()
.forEach((key) => {
const value = input[key];
if (value !== undefined) {
sorted[key] = sortObject(value);
}
});
return sorted;
}
return input;
}
/**
* 生成规范化 JSON
*/
function canonicalJson(data) {
return JSON.stringify(sortObject(data));
}
/**
* MD5 小写
*/
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
/**
* 生成签名
* sign = md5(ts + "|" + nonce + "|" + canonicalBody + "|" + appSecret)
*/
function makeSign(body, ts, nonce, appSecret) {
const canonicalBody = canonicalJson(body);
const raw = `${ts}|${nonce}|${canonicalBody}|${appSecret}`;
return md5(raw);
}
/**
* 组装请求
*/
function buildSignedRequest(body) {
const ts = Date.now().toString();
const nonce = randomNonce(16);
const appSecret = "demo_app_secret_2024";
const sign = makeSign(body, ts, nonce, appSecret);
return {
headers: {
"Content-Type": "application/json",
"x-ts": ts,
"x-nonce": nonce,
"x-sign": sign,
},
data: body,
};
}
/**
* 演示本地打印
*/
async function main() {
const body = {
keyword: "python",
page: 1,
pageSize: 10,
};
const req = buildSignedRequest(body);
console.log("=== 请求体 ===");
console.log(req.data);
console.log("=== 请求头 ===");
console.log(req.headers);
console.log("=== 规范化 JSON ===");
console.log(canonicalJson(body));
// 这里用 httpbin 演示请求回显,便于你验证结构
const resp = await axios.post("https://httpbin.org/post", req.data, {
headers: req.headers,
timeout: 10000,
});
console.log("=== 服务端回显 headers ===");
console.log(resp.data.headers);
console.log("=== 服务端回显 json ===");
console.log(resp.data.json);
}
main().catch((err) => {
console.error("执行失败:", err.message);
});
运行:
node sign-demo.js
版本二:使用 crypto-js
有些前端代码本身就是 CryptoJS.MD5(...),为了方便对照,也给一个版本。
新建 sign-cryptojs.js:
const CryptoJS = require("crypto-js");
function randomNonce(length = 16) {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function sortObject(input) {
if (Array.isArray(input)) {
return input.map(sortObject);
}
if (input && typeof input === "object") {
const sorted = {};
Object.keys(input)
.sort()
.forEach((key) => {
const value = input[key];
if (value !== undefined) {
sorted[key] = sortObject(value);
}
});
return sorted;
}
return input;
}
function canonicalJson(data) {
return JSON.stringify(sortObject(data));
}
function makeSign(body, ts, nonce, appSecret) {
const canonicalBody = canonicalJson(body);
const raw = `${ts}|${nonce}|${canonicalBody}|${appSecret}`;
return CryptoJS.MD5(raw).toString(CryptoJS.enc.Hex);
}
const body = {
keyword: "python",
page: 1,
pageSize: 10,
};
const ts = Date.now().toString();
const nonce = randomNonce(16);
const appSecret = "demo_app_secret_2024";
console.log({
ts,
nonce,
sign: makeSign(body, ts, nonce, appSecret),
});
如何把浏览器里的签名函数“搬”到本地
如果你已经在前端代码里找到了真实签名函数,最稳的做法通常不是“重写”,而是先最小代价迁移。
方法一:直接拷贝必要函数
如果签名函数依赖不多,比如:
function m(data, ts, nonce) {
const s = JSON.stringify(k(data));
return md5(ts + "|" + nonce + "|" + s + "|" + "demo_app_secret_2024");
}
那你可以直接把这些依赖函数一起抠出来,放进 Node 环境跑。
优点:
- 快
- 误差小
缺点:
- 如果依赖浏览器对象,可能跑不起来
方法二:补环境再执行
有些签名函数会依赖这些对象:
windowdocumentnavigatorlocationlocalStorage
你可以在 Node 里做最小 mock:
global.window = global;
global.navigator = {
userAgent: "Mozilla/5.0 demo",
platform: "Win32",
};
global.location = {
href: "https://example.com/",
};
global.localStorage = {
getItem(key) {
const store = {
token: "demo_token",
};
return store[key] || null;
},
};
如果只是读取少量环境变量,这种方式够用。
方法三:浏览器内 Hook 观察中间值
当代码太绕、太混淆时,我更建议先在浏览器里 Hook,而不是死啃源码。
比如 Hook JSON.stringify、CryptoJS.MD5、XMLHttpRequest.send、fetch,直接观察中间输入。
示例:Hook fetch
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log("[fetch args]", args);
const res = await rawFetch.apply(this, args);
return res;
};
示例:Hook 某个摘要函数
const rawMd5 = CryptoJS.MD5;
CryptoJS.MD5 = function (...args) {
console.log("[MD5 input]", args[0] + "");
const result = rawMd5.apply(this, args);
console.log("[MD5 output]", result.toString());
return result;
};
这个办法我个人非常常用。因为有时候你追半天函数栈,不如直接在关键点把输入打印出来。
一次完整的定位路径示例
下面给出一个更接近实战的定位流程图。你以后遇到类似站点,可以直接照着走。
flowchart LR
A[抓包找到目标请求] --> B[观察 Header/Body/Query 中异常字段]
B --> C[对比两次请求 找变化项]
C --> D[根据 Initiator 跳到源码]
D --> E[定位请求封装 axios/fetch/xhr]
E --> F[找到签名写入位置]
F --> G[跟入签名函数]
G --> H[确认规范化规则 排序/序列化]
H --> I[确认参与字段 ts nonce token salt]
I --> J[确认算法 MD5/HMAC/AES]
J --> K[本地最小复现]
K --> L[发送请求验签]
常见坑与排查
这一部分非常关键。我把最常见、最容易浪费时间的坑都列出来。
1. 参数顺序不一致
现象:
- 你看起来所有字段都对
- 算法也对
- 但签名始终不一致
排查:
打印签名前的原始字符串,逐字符对比浏览器与本地版本。
console.log("raw string:", raw);
很多问题最终会发现是:
- 你本地对象遍历顺序不同
- 浏览器端做了 key 排序
- 某个嵌套对象没递归排序
2. JSON.stringify 的结果不一致
现象:
- 肉眼看对象一样
- 但摘要不同
常见原因:
undefined字段被忽略Date被转成字符串- 数字和字符串混用
- 布尔值被转成
"true"/"false"
建议在浏览器断点处直接打印:
console.log(JSON.stringify(data));
然后和本地结果逐字比较。
3. 时间戳窗口限制
有些服务端要求:
- 时间戳必须在当前时间前后 5 分钟内
- 过期直接拒绝
现象:
- 本地算出来的 sign 和浏览器一致
- 但请求仍然 403
排查:
确认你本地使用的是实时生成 ts,而不是抓包里抄的旧值。
4. 随机数格式不对
有些 nonce 不只是随机字符串,而是有格式要求:
- 固定长度 16/32
- 只能小写
- 必须十六进制
- 必须 UUID 格式
你如果随便生成一个,服务端可能直接拦掉。
5. Header 名字大小写问题
理论上 HTTP Header 不区分大小写,但有些网关、某些前端封装和日志系统会让你误判。
建议抓浏览器真实请求,看它到底发送的是:
x-signX-SignX-SIGN
尤其是一些加签逻辑会参与“Header 名字本身”,这时大小写就必须保持一致。
6. Body 实际上传输格式与你看到的不一样
抓包里看到“像 JSON”,但真实请求可能是:
application/x-www-form-urlencoded- 表单 multipart
- URL query 拼接
- 先压缩再编码
如果服务端签名的是原始传输串,而你本地签的是对象 JSON,自然不可能一样。
7. 忽略了 token / uid / deviceId
有些签名不仅依赖 body,还会拼这些信息:
- 登录 token
- 用户 ID
- 设备 ID
- sessionStorage/localStorage 中的值
- 某个初始化接口返回的 seed
这种情况下你只盯着请求体,怎么也算不出来。
8. 混淆函数返回的不是最终 sign
有时你跟到一个函数,以为它已经是最终签名,结果它只是:
- 中间摘要
- AES 明文
- Base64 编码前结果
- 二次封装前 token
这类问题最好的办法就是:从 Header 写入点反向追值,确认最后落进去的那个字符串,到底经过了几层处理。
一个实用的排查脚本:比对浏览器与本地输入
当你怀疑“差一点点”的时候,最有用的是把待签名原文落盘,然后 diff。
浏览器端临时输出
console.log("SIGN_RAW_BROWSER=", raw);
console.log("SIGN_RESULT_BROWSER=", sign);
Node 端输出
console.log("SIGN_RAW_NODE=", raw);
console.log("SIGN_RESULT_NODE=", sign);
如果字符串很长,建议写文件:
const fs = require("fs");
fs.writeFileSync("browser_raw.txt", raw, "utf8");
然后用 diff 工具对比,通常很快就能发现:
- 少了一个分隔符
- 多了一个空格
- 少拼了一个 token
- 排序少递归了一层
这一步非常朴素,但极其有效。
安全/性能最佳实践
虽然这篇文章是从“逆向分析”角度讲,但如果你自己也在做前端签名设计,这里有几点非常值得注意。
安全最佳实践
1. 不要把“前端签名”当成真正安全边界
只要密钥、算法、流程运行在前端,理论上就能被还原。前端加签的价值更多在于:
- 提高滥用门槛
- 增加简单脚本调用难度
- 配合风控做基础防护
真正的安全边界应放在服务端:
- 用户鉴权
- 权限控制
- 频率限制
- 风险识别
- 行为校验
2. 避免把长期固定密钥硬编码在前端
固定盐值一旦下发到浏览器,就等于可被提取。更合理的做法是:
- 使用短时效 token
- 服务端下发会话级动态因子
- 配合设备态、行为态和风控策略
3. 关键校验放服务端
前端可以参与签名,但服务端必须独立校验:
- 参数合法性
- 时间窗口
- nonce 去重
- token 是否有效
- 签名是否匹配
4. 防重放设计
如果签名里有 ts 和 nonce,服务端最好:
- 校验时间窗口
- 记录 nonce 使用状态
- 在一定时间内拒绝重复请求
不然别人抓到一次合法包,直接重放就行了。
性能最佳实践
1. 避免对超大对象做深度递归签名
如果每次都对很大的嵌套对象递归排序和序列化,会有明显性能成本。更合理的做法是:
- 只签关键字段
- 对列表分页数据避免全量参与签名
- 固定规范化规则,减少深度遍历
2. 不要在主线程做过重加密
如果前端签名链路包含大对象处理或复杂加密,页面交互可能卡顿。可以考虑:
- Web Worker
- 降低签名数据量
- 预计算固定部分
3. 统一序列化实现
前后端务必统一:
- 字段顺序
- 空值策略
- 编码格式
- 字符串拼接规则
否则会出现“线上偶发验签失败”这种非常难排查的问题。
一个最小“本地验签”思路
如果你已经复现出签名函数,建议按这个顺序验证,不要一步到位直接打目标接口。
验证顺序
- 先验证本地签名函数是否稳定
- 再验证与浏览器同参数下是否生成一致 sign
- 再构造完整 Header
- 最后请求测试接口或回显接口
- 再切到真实目标接口验证
自测代码
const body = {
keyword: "python",
page: 1,
pageSize: 10,
};
const ts = "1726440000123";
const nonce = "8f3c2d9b6a1e4c7f";
const secret = "demo_app_secret_2024";
const sign1 = makeSign(body, ts, nonce, secret);
const sign2 = makeSign(
{ pageSize: 10, keyword: "python", page: 1 },
ts,
nonce,
secret
);
console.log("sign1 =", sign1);
console.log("sign2 =", sign2);
console.log("same? ", sign1 === sign2);
如果你的规范化做对了,这两个 sign 应该一致。
什么时候该怀疑不是“签名问题”
这也是实战里很容易误判的一点。
如果你已经确认签名完全一致,但请求依旧失败,要开始怀疑别的层面:
- Cookie 失效
- CSRF token 缺失
- Referer / Origin 校验
- TLS 指纹或浏览器指纹
- 验证码 / 人机校验
- 网关风控
- HTTP/2 或特殊 Header 要求
- 请求顺序依赖前置接口
也就是说,签名复现成功,不代表整个请求上下文已经完整复现。中级阶段一定要建立这个意识,不然很容易卡死在错误方向上。
总结
这类 Web 逆向的核心,不是“会几个加密算法”,而是能不能把签名链路拆开看清楚:
- 先抓包,明确哪些字段在变
- 再从请求入口定位 Header/Body 的组装位置
- 跟到签名函数,重点看规范化规则
- 确认参与字段:
ts、nonce、token、salt - 最后才是算法复现
- 用浏览器中间值和本地结果逐字对比,快速收敛误差
如果你只记一个结论,我建议记这个:
前端签名复现,最容易错的不是算法,而是“签名前原文”是否完全一致。
最后给几个可执行建议:
- 遇到签名问题,优先搜索请求头字段名,而不是直接搜
md5 - 不要跳过“同请求多次对比”这一步,它能快速缩小范围
- 本地复现前,先在浏览器断点里拿到真实输入输出
- 如果代码太混淆,优先 Hook 关键函数,看中间值
- 复现成功后,立刻整理成通用模板,下一次会快很多
只要你把这套路径练熟,绝大多数“前端加密参数定位与签名复现”的题,都会从“无从下手”变成“只是工作量问题”。这就是方法论真正的价值。