背景与问题
很多中级开发者第一次做 Web 逆向时,卡住的并不是“不会写代码”,而是不知道该从哪里下手。
你抓到一个请求,发现参数里多了这些东西:
signtokentimestampnoncemt- 一串看起来像摘要的十六进制字符串
这时候最常见的误区有两个:
- 上来就全局搜
sign,结果搜出几百处调用,越看越乱。 - 拿到请求就直接猜算法,MD5、SHA1、SHA256 轮着试,试到怀疑人生。
我自己早期也踩过这个坑:抓到包以后,以为只要把参数拼一拼哈希一下就行,结果折腾半天才发现,真正参与签名的并不只是接口参数,还包括了固定盐值、时间戳、字段排序规则,甚至还有一次编码转换。
这篇文章的目标不是讨论“某个站点怎么破”,而是讲一套可复用的定位方法:
如何从浏览器抓包出发,逐步定位前端签名参数生成逻辑,并在本地完整复现。
适用场景:学习接口调试、理解前端签名流程、排查自动化请求失败原因
不适用场景:绕过权限、攻击未授权系统
前置知识与环境准备
如果你已经会下面这些,可以直接跳到实战部分:
- 会用浏览器开发者工具(Chrome DevTools)
- 知道
Network / Sources / Console - 能看懂基础 JavaScript
- 会用 Node.js 跑脚本
建议准备:
- Chrome 或 Edge 浏览器
- Node.js 16+
- 一个抓包代理工具(可选)
- 格式化/反混淆工具(可选)
本文示例用一个教学化的签名模型来演示,流程和真实业务很接近:
- 前端收集请求参数
- 加入
timestamp、nonce - 对参数按 key 排序
- 拼接固定盐值
appSecret - 使用
MD5生成sign - 发起请求
虽然示例简单,但定位思路是通用的。
背景分析:签名到底在解决什么问题
先别急着逆向,先理解为什么前端会有“签名”。
常见目的有:
- 防止接口被随便拼参数重放
- 防止关键参数被篡改
- 做请求来源校验
- 给风控系统提供额外判定信息
但这里有个现实问题:
只要签名逻辑在前端执行,浏览器最终就必须拿到算法和参与签名的数据。
所以对开发者来说,难点通常不是“算法不存在”,而是:
- 代码被压缩混淆了
- 函数链路很长
- 调用入口很多
- 参数在发送前被二次加工
- 签名逻辑可能塞在 webpack 模块、闭包、hook 过的请求库里
所以我们真正要做的不是“猜算法”,而是:
定位调用链 → 识别关键输入 → 还原拼接规则 → 在本地复现
核心原理
1. 签名生成的典型结构
前端签名通常由下面几类输入组成:
- 业务参数:如
page=1&keyword=phone - 动态参数:时间戳、随机串、设备标识
- 静态参数:appId、版本号、渠道号
- 密钥材料:盐值、固定 token、内部常量
生成流程通常像这样:
flowchart TD
A[采集请求参数] --> B[补充 timestamp/nonce]
B --> C[字段排序]
C --> D[拼接字符串]
D --> E[加入固定盐值]
E --> F[摘要计算 MD5/SHA/HMAC]
F --> G[写入 sign]
G --> H[发送请求]
2. 你真正需要找的不是“sign”,而是“发送前最后一步”
很多人会在源码里搜:
signmd5shacrypto
这样有时候能命中,但不稳定。更稳的思路是:
- 先找到目标请求
- 定位请求发起点
- 在发起点往上追参数加工过程
- 找到 sign 写入时刻
换句话说,不是“先找算法”,而是“先找请求是怎么发出去的”。
3. 常见签名算法线索
从经验看,可以先观察签名值的形态:
- 32 位十六进制:常见 MD5
- 40 位十六进制:常见 SHA1
- 64 位十六进制:常见 SHA256
- base64 风格:可能是 HMAC、AES、或摘要后二次编码
- 很短但变化频繁:可能做了截断、位运算、字符映射
但注意:长度只能作为线索,不能直接下结论。
定位思路:从请求出发反推签名逻辑
这一段是全文最重要的部分。
步骤 1:在 Network 锁定目标请求
打开浏览器开发者工具,进入 Network:
- 勾选
Preserve log - 触发目标接口
- 观察请求方法、URL、QueryString、Request Payload、Headers
重点看:
- 哪些参数每次都变
- 哪些参数看起来像签名
- 请求发起时机是点击、滚动、页面加载还是定时器
可以先记录一份样本,比如:
page=1
keyword=phone
timestamp=1710000000
nonce=ab12cd34
sign=5f4dcc3b5aa765d61d8327deb882cf99
步骤 2:看 Initiator 和调用栈
在目标请求详情里看:
InitiatorCall Stack
这里经常能直接看到:
- 是
fetch - 还是
XMLHttpRequest - 还是 axios 封装层
如果运气好,你会直接跳到发请求的源码位置。
步骤 3:在 Sources 里下断点
建议在这些点下断:
fetch调用前XMLHttpRequest.prototype.send- axios 请求拦截器
- 请求公共封装函数
如果页面比较复杂,可以直接在 Console 注入 hook。
实战代码(可运行)
下面我用一个完整的小型示例来演示“签名生成”和“本地复现”。
示例签名规则
规则如下:
- 业务参数:
page,keyword - 动态参数:
timestamp,nonce - 所有参数按 key 升序排序
- 拼接为
key=value&key=value... - 在末尾追加
&secret=demo_secret_123 - 对结果做 MD5,得到
sign
前端页面里的示例代码
这是一个浏览器侧示例,模拟真实项目中发请求前的签名逻辑:
function md5(text) {
return CryptoJS.MD5(text).toString();
}
function buildSign(params) {
const secret = "demo_secret_123";
const sortedKeys = Object.keys(params).sort();
const query = sortedKeys
.map((key) => `${key}=${params[key]}`)
.join("&");
const raw = `${query}&secret=${secret}`;
return md5(raw);
}
async function sendRequest() {
const params = {
page: 1,
keyword: "phone",
timestamp: Math.floor(Date.now() / 1000),
nonce: Math.random().toString(16).slice(2, 10),
};
params.sign = buildSign(params);
return fetch("/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
}
如何在浏览器里 hook 请求
如果你还没找到请求封装位置,可以先 hook fetch:
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log("[fetch args]", args);
const [url, config] = args;
if (config && config.body) {
try {
console.log("[request body]", JSON.parse(config.body));
} catch (e) {
console.log("[request body raw]", config.body);
}
}
debugger;
return rawFetch.apply(this, args);
};
这段代码的作用很直接:
- 打印请求参数
- 在发送前暂停
- 让你顺着调用栈往上看是谁生成了
sign
这一步在真实站点里非常有效,因为你不用先读完混淆代码,先把请求截在门口。
更进一步:hook 摘要函数
如果怀疑用了 CryptoJS.MD5,可以继续 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());
debugger;
return result;
};
你会立刻看到:
- 被哈希的原始字符串是什么
- 输出值是什么
- 调用点在哪里
很多时候,看到 MD5 input 的那一刻,问题就已经解决了。
逐步复现:Node.js 本地实现签名
定位出规则后,下一步是在本地复现。下面给出可运行版本。
Node.js 版本签名函数
const crypto = require("crypto");
function buildSign(params) {
const secret = "demo_secret_123";
const sortedKeys = Object.keys(params).sort();
const query = sortedKeys
.map((key) => `${key}=${params[key]}`)
.join("&");
const raw = `${query}&secret=${secret}`;
return crypto.createHash("md5").update(raw, "utf8").digest("hex");
}
function buildRequestData(page, keyword) {
const data = {
page,
keyword,
timestamp: Math.floor(Date.now() / 1000),
nonce: Math.random().toString(16).slice(2, 10),
};
data.sign = buildSign(data);
return data;
}
const reqData = buildRequestData(1, "phone");
console.log(reqData);
运行:
node sign-demo.js
输出类似:
{
page: 1,
keyword: 'phone',
timestamp: 1710000000,
nonce: 'ab12cd34',
sign: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
}
发起真实请求的示例
如果你要模拟请求,可以继续这样写:
const crypto = require("crypto");
async function main() {
const data = {
page: 1,
keyword: "phone",
timestamp: Math.floor(Date.now() / 1000),
nonce: Math.random().toString(16).slice(2, 10),
};
data.sign = buildSign(data);
const res = await fetch("http://localhost:3000/api/search", {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0",
},
body: JSON.stringify(data),
});
const text = await res.text();
console.log(text);
}
function buildSign(params) {
const secret = "demo_secret_123";
const sortedKeys = Object.keys(params).sort();
const query = sortedKeys
.map((key) => `${key}=${params[key]}`)
.join("&");
const raw = `${query}&secret=${secret}`;
return crypto.createHash("md5").update(raw, "utf8").digest("hex");
}
main().catch(console.error);
定位链路示意图
实际工作中,我通常会按下面这个顺序定位:
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant R as 请求封装层
participant A as 接口服务
U->>P: 点击搜索
P->>P: 组装业务参数
P->>S: 生成 timestamp/nonce/sign
S-->>P: 返回 sign
P->>R: 注入完整请求参数
R->>A: 发送 HTTP 请求
A-->>R: 返回结果
R-->>P: 渲染页面
这张图想表达的重点是:
签名函数通常不直接发请求,请求层也通常不直接计算签名。
你要做的是把这两层接起来。
如何判断自己已经“复现成功”
很多人以为“本地算出了一个 sign”就叫成功,其实不够。
真正的复现成功,至少要满足下面几点:
- 同一组输入,本地
sign与浏览器一致 - 同步请求后,服务端不报签名错误
- 时间戳窗口内请求可通过
- 参数顺序、编码、空值处理都一致
验证清单
你可以按这个清单逐项确认:
- 参数名完全一致
- 参数值类型一致(字符串/数字)
- 排序规则一致
- 拼接分隔符一致
- URL 编码时机一致
- 哈希算法一致
- 输出格式一致(hex/base64/大写/小写)
- 时间戳位数一致(秒/毫秒)
- nonce 规则一致
- 请求头中是否还有额外参与签名字段
这份清单看着普通,但真能帮你省很多时间。我自己排查签名失败时,最常见就是编码时机和字段类型出了问题。
常见坑与排查
1. 看起来是 MD5,其实参与签名的字符串不对
最常见的误判是:
- 算法找对了
- 结果却对不上
这通常不是算法错,而是原始字符串错了。重点排查:
- 有没有漏字段
- 排序规则是否一致
- 是否拼了额外盐值
- 参数值是否做过
encodeURIComponent - 是否把
sign自己也放进了签名串
排查建议
直接打印浏览器里摘要函数的输入值,再和本地拼接结果逐字符比对。
2. 时间戳单位错了
常见有三种:
- 秒:
1710000000 - 毫秒:
1710000000000 - 自定义格式:如
"1710000000.123"
如果你发现除了 timestamp 以外都没问题,优先检查这个。
3. 参数顺序不是“对象遍历顺序”,而是“显式排序”
很多代码表面上看像这样:
JSON.stringify(params)
但真正参与签名的,可能是另外一个经过排序的新对象。
不要被表象迷惑,一定要看最终进入摘要函数的字符串。
4. 请求体和签名串不是同一份数据
这也是一个很隐蔽的坑。
比如:
- 请求体是 JSON
- 但签名用的是 query string 形式
- 或者某些字段只参与签名,不参与提交
- 或者请求头里的
x-token也参与签名
典型现象
- 你用抓包里的 body 复现,始终失败
- 浏览器发出去能成功,本地却总是“签名错误”
排查方法
从发送前断点看最终 payload 和 sign 输入,别只看 Network 面板表面信息。
5. 混淆后函数名完全不可读
例如你会看到这种代码:
a = b(c(d(e(f)))))
别硬啃。中级开发者最该学会的是动态调试优先:
- 下断点
- hook 函数
- 看调用栈
- 看入参和返回值
真实项目里,静态阅读常常只是辅助手段。
6. 摘要函数被二次封装
有些项目不会直接写 CryptoJS.MD5(str),而是这样:
function x(input) {
return y(z(input));
}
这时你可以:
- 全局搜索
createHash - 搜
digest - 搜
hex - 搜
toString() - 搜
WordArray
如果是 webpack 打包项目,还可以在格式化后看模块导出关系。
一张排查状态图
当你遇到“签名总是不对”时,可以按这个状态流走:
stateDiagram-v2
[*] --> 抓到目标请求
抓到目标请求 --> 找到发起点
找到发起点 --> 观察发送前参数
观察发送前参数 --> 定位签名函数
定位签名函数 --> 记录摘要输入
记录摘要输入 --> 本地复现
本地复现 --> 结果一致: 成功
本地复现 --> 结果不一致: 检查排序编码时间戳
检查排序编码时间戳 --> 记录摘要输入
安全/性能最佳实践
这部分不只是“写给防守方”,对逆向分析者也很重要,因为你能从这里判断系统可能采用了哪些策略。
1. 不要把“前端签名”当成真正密钥保护
如果签名逻辑和密钥都下发到前端,那么它更多是:
- 提高调用门槛
- 配合风控做校验
- 过滤低质量滥用
而不是绝对安全边界。
可执行建议
服务端应该:
- 校验签名
- 校验时间窗口
- 校验 nonce 去重
- 绑定会话或设备信息
- 对异常频率做限流
2. 尽量避免重型加密逻辑阻塞主线程
前端如果每次请求都做复杂加密,尤其是大对象签名,会影响交互流畅度。
建议
- 签名输入尽量简化
- 避免无意义字段参与签名
- 必要时用 Web Worker
- 摘要而非对大体积数据做重复序列化
3. 参数规范必须明确
一个签名方案如果没有统一规范,后续维护会很痛苦。
比如到底是:
- 空值参与还是不参与?
null转空串还是字符串"null"?- 数组怎么拼?
- 对象嵌套如何展开?
建议
把以下内容写成文档:
- 字段排序规则
- 编码规则
- 时间戳单位
- 输出格式
- 版本号策略
这不只是为了开发方便,也是为了避免“前后端各自理解不同”。
4. 为签名方案留版本号
这条是我非常建议的。真实业务里签名算法总会升级,如果没有版本字段,你会在兼容老客户端时非常被动。
例如:
{
"page": 1,
"keyword": "phone",
"timestamp": 1710000000,
"nonce": "ab12cd34",
"signVersion": "v2",
"sign": "xxxx"
}
这样后端可以按版本选择校验逻辑。
实战经验:一个更稳的工作流
如果让我带一个中级开发者从零做,我会建议你用这套流程:
第一步:先抓请求,不碰源码
先确认:
- 哪个请求是目标请求
- 哪些参数是动态的
- 是否存在签名失败提示
第二步:在发送前拦截
优先 hook:
fetchXMLHttpRequest.send- axios 拦截器
目的不是马上理解所有代码,而是先拿到:
- 最终请求体
- 最终请求头
- 发送前调用栈
第三步:盯摘要函数输入
只要你能看到:
MD5/SHA的输入串- 输入串和输出 sign 的对应关系
基本就进入收尾阶段了。
第四步:本地最小复现
不要一上来写整套自动化脚本。先写一个最小版:
- 固定参数
- 固定 timestamp
- 固定 nonce
- 算出和浏览器一致的 sign
第五步:再接入真实请求
只有当“同输入同输出”完全一致时,才去发接口。
否则你只是在把错误流程自动化。
总结
前端签名逆向这件事,最关键的不是“会不会某种加密算法”,而是有没有一套稳定的方法论。
你可以记住这条主线:
- 先锁定目标请求
- 再找到请求发起点
- 在发送前拦截参数
- 盯住摘要函数输入
- 本地最小化复现
- 逐项校验排序、编码、时间戳与输出格式
如果你现在就要开始实战,我建议按下面这个最小行动方案来:
- 打开 Network,找到目标请求
- 查看 Initiator,定位请求入口
- hook
fetch或XHR.send - hook
CryptoJS.MD5或摘要函数 - 记录签名输入串
- 用 Node.js 复写一份同样的签名逻辑
- 固定参数做逐步比对
最后提醒一个边界条件:
本文讲的是分析与调试方法,适用于你有权测试、学习或维护的系统。
对未授权目标进行绕过、批量滥用或攻击,不属于正常工程实践。
如果你把“猜算法”的习惯换成“抓发送前现场”,Web 逆向的成功率会高很多。这个转变,往往就是从初学到中级最关键的一步。