Web逆向实战:基于浏览器抓包与 JavaScript 动态调试定位前端签名算法的完整方法
很多人第一次做 Web 逆向,都会卡在同一个地方:接口明明抓到了,请求参数也抄全了,但服务端就是提示签名错误。
这类场景往往不是“少了个字段”这么简单,而是前端在发送请求前,动态计算了一个签名值,比如 sign、token、auth、x-signature 之类。
这篇文章我想从“实战排查”的角度,带你完整走一遍:
- 先用浏览器抓包确认签名存在;
- 再用 JavaScript 动态调试锁定签名生成位置;
- 最后把核心算法抽出来,在浏览器控制台或 Node.js 中复现。
重点不是某个站点的特定实现,而是一套可迁移的方法。只要页面签名逻辑运行在前端,这套思路大概率都能落地。
背景与问题
典型场景如下:
- 页面接口请求头里带有
X-Sign、Authorization或加密参数; - 相同 URL、相同业务参数,直接重放请求却失败;
- 请求成功必须依赖浏览器当前执行环境;
- 页面 JS 经过混淆、压缩、Webpack 打包,直接搜关键字很难找到。
你会发现,问题并不在“会不会抓包”,而在于:
如何从一堆前端代码里,定位出签名算法的真实入口。
我当时踩过一个很典型的坑:只盯着 Network 面板里请求参数看,始终以为签名是请求发出前拼出来的。后来动态下断点才发现,签名是在一个公共请求封装器里统一注入的,而且字段名还会被二次映射。
所以经验是:不要一开始就猜算法,先确认调用链。
前置知识与环境准备
建议你至少具备这些基础:
- 会用 Chrome DevTools
- 知道 XHR / Fetch 的基本区别
- 能看懂基本的 JavaScript
- 对 MD5、SHA1、SHA256、HMAC、Base64、时间戳有基本概念
准备环境:
- Chrome 浏览器
- DevTools
- 一个本地 Node.js 环境(用于复现算法)
- 可选:Charles / Fiddler / mitmproxy 辅助观察流量
核心原理
前端签名算法的本质,通常是下面这条链路:
- 收集业务参数
- 补充公共参数,如时间戳、随机数、版本号
- 以固定顺序拼接
- 使用摘要或加密算法处理
- 将结果放到请求头或请求参数中
常见模式有:
sign = md5(sorted_query + secret)sign = sha256(timestamp + body + nonce)sign = hmac_sha256(data, key)sign = base64(某种序列化结果)- 先
JSON.stringify,再加密 - 先经过
encodeURIComponent,再拼接
一个典型调用路径
flowchart TD
A[用户触发请求] --> B[业务层组装参数]
B --> C[公共请求封装器]
C --> D[注入时间戳/nonce]
D --> E[调用签名函数]
E --> F[写入Header或Query]
F --> G[发送XHR/Fetch请求]
从逆向角度看,真正关键的不是“算法名字”,而是这几个问题:
- 签名前的原始数据是什么?
- 字段排序规则是什么?
- 是否过滤空值?
- 是否有固定盐值或 secret?
- 是否依赖浏览器环境,如
window.navigator、localStorage、cookie? - 是否有二次编码?
方法总览:先抓包,再断点,再复现
如果让我总结成一句操作原则,就是:
从网络请求反推调用点,再从调用点回溯签名输入。
整体步骤图
flowchart LR
A[Network抓到目标请求] --> B[确认签名字段位置]
B --> C[观察Payload/Header变化]
C --> D[Sources中拦截XHR或Fetch]
D --> E[定位请求封装函数]
E --> F[回溯sign生成函数]
F --> G[提取输入参数与算法]
G --> H[在控制台/Node中复现]
第一步:浏览器抓包,确认签名在哪
打开 DevTools 的 Network 面板,触发目标请求,重点看三处:
- Headers:请求头里是否有签名字段
- Payload / Query String Parameters:是否有
sign - Initiator:由哪个脚本触发
重点观察内容
- 签名是在 Header 还是 Body 里?
- 同一接口多次请求,签名会不会变化?
- 请求体不变时,签名是否仍会变化?
- 是否存在时间戳、nonce、traceId 之类的动态字段?
比如看到:
POST /api/data/list
Content-Type: application/json
X-Timestamp: 1710000000
X-Nonce: 8f3d1c
X-Sign: 5f4dcc3b5aa765d61d8327deb882cf99
这时先别急着猜 X-Sign 是 MD5。
更应该立刻想到:
X-Sign很可能依赖X-TimestampX-Nonce可能也参与签名- 如果 Body 是 JSON,签名输入可能是字符串化后的 JSON
一个简单验证动作
重复发送相同请求,观察:
- 只改时间戳,签名是否变化?
- 只改一个业务字段,签名是否变化?
- 不刷新页面,多次发相同请求,nonce 是否递增?
这些现象会直接缩小排查范围。
第二步:定位请求发起方式
现代站点常见两种:
XMLHttpRequestfetch
要先确认页面是怎么发请求的,否则断点位置会打偏。
方法 1:看 Network 的 Initiator
在请求详情中查看 Initiator,可以直接跳到调用栈相关脚本。
方法 2:全局 Hook
如果代码太混淆,我常用这种“笨但有效”的方式:先 Hook 一层,把参数打印出来。
Hook XHR
(() => {
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
const setRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._method = method;
this._url = url;
console.log('[XHR open]', method, url);
return open.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
if (!this._headers) this._headers = {};
this._headers[key] = value;
console.log('[XHR header]', key, value);
return setRequestHeader.call(this, key, value);
};
XMLHttpRequest.prototype.send = function(body) {
console.log('[XHR send]', {
method: this._method,
url: this._url,
headers: this._headers,
body
});
return send.call(this, body);
};
})();
Hook Fetch
(() => {
const rawFetch = window.fetch;
window.fetch = async function(input, init = {}) {
console.log('[fetch request]', {
input,
init
});
const res = await rawFetch.apply(this, arguments);
return res;
};
})();
这段代码可以直接在控制台运行。
作用不是“破解”,而是先看清:
- 请求在哪发
- Header 什么时候被写入
- Body 最终长什么样
第三步:动态调试,卡住签名生成瞬间
这是整篇最关键的部分。
如果你已经知道签名字段名,比如 X-Sign,那么最直接的方法是:
方法 A:对 setRequestHeader 下断点
当请求头被设置时,断住执行,再顺着调用栈往上找。
示例思路
- 在 Sources 中找到
XMLHttpRequest.prototype.setRequestHeader - 或者直接用上面 Hook 代码,在打印处加
debugger
(() => {
const raw = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
if (String(key).toLowerCase().includes('sign')) {
console.log('命中签名Header', key, value);
debugger;
}
return raw.call(this, key, value);
};
})();
页面再次发请求时,会在浏览器里停住。
这时看右侧的:
- Call Stack
- Scope
- Local Variables
你通常能看到:
- 当前签名值
- 调用签名函数的上层函数
- 输入数据对象
- 时间戳、nonce 等中间变量
方法 B:XHR/Fetch Breakpoints
Chrome DevTools 自带断点能力:
- Sources
- XHR/fetch Breakpoints
- 添加目标 URL 关键字,比如
/api/data/list
请求发起时就会中断。
这招适合你已经知道目标接口,但不知道代码入口在哪。
方法 C:搜索可疑特征
如果签名在 Body 里,不好从 Header 下断点,可以搜这些关键词:
md5sha1sha256hmacCryptoJSsigntokennoncetimestampsortObject.keys
但要注意,现代打包代码中变量名可能被压缩成 a, b, c。
此时搜索“算法库名”往往比搜“业务字段名”更有效。
第四步:识别签名输入与处理顺序
找到签名函数后,不要只抄最后一行。
真正要搞清楚的是:输入是怎么来的。
常见签名伪代码
function buildSign(params, secret) {
const keys = Object.keys(params)
.filter(k => params[k] !== '' && params[k] !== null && params[k] !== undefined)
.sort();
const plain = keys.map(k => `${k}=${params[k]}`).join('&');
return md5(plain + secret);
}
这里面至少有 4 个关键点:
- 空值被过滤
- 键名按字典序排序
- 用
&拼接 - 最后拼上
secret
如果你漏掉任何一个,结果都对不上。
一个常见误区
很多同学看到 JSON.stringify(body) 就直接照搬。
但你要注意:
- 字段顺序是否稳定?
- 是不是先排序再 stringify?
- 嵌套对象是否递归排序?
- 数组是否参与排序?
这类差异会直接影响结果。
签名生成时序图
sequenceDiagram
participant U as 用户操作
participant P as 页面业务逻辑
participant R as 请求封装器
participant S as 签名函数
participant A as 接口服务端
U->>P: 触发查询
P->>R: 传入业务参数
R->>R: 注入timestamp/nonce
R->>S: 传入待签名数据
S-->>R: 返回sign
R->>A: 发送带sign的请求
A-->>R: 校验通过并返回数据
实战代码:从页面签名到 Node.js 复现
下面给一个可运行的完整示例。
这个示例模拟常见前端签名规则:
- 合并业务参数与公共参数
- 过滤空值
- 键名排序
- 拼接成查询串
md5(query + secret)
注意:这里是教学示例,用于演示定位与复现方法,不针对任何具体站点。
浏览器端模拟代码
可以直接在控制台执行:
function fakeMd5(str) {
// 教学环境下不引第三方库,这里不是真实 md5
// 真正复现请使用 CryptoJS 或 Node.js crypto
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(16);
}
function buildQuery(params) {
return Object.keys(params)
.filter(key => params[key] !== '' && params[key] !== null && params[key] !== undefined)
.sort()
.map(key => `${key}=${String(params[key])}`)
.join('&');
}
function signParams(params, secret) {
const plain = buildQuery(params);
const sign = fakeMd5(plain + secret);
return { plain, sign };
}
function sendRequest() {
const data = {
keyword: 'phone',
page: 1,
pageSize: 20,
timestamp: 1710000000,
nonce: 'abc123'
};
const secret = 'demo_secret';
const { plain, sign } = signParams(data, secret);
console.log('待签名字符串:', plain);
console.log('签名结果:', sign);
return {
url: '/api/data/list',
headers: {
'X-Sign': sign,
'X-Timestamp': data.timestamp,
'X-Nonce': data.nonce
},
body: JSON.stringify(data)
};
}
console.log(sendRequest());
Node.js 复现代码
下面用 Node.js 自带 crypto 做一个真实 MD5 版本:
const crypto = require('crypto');
function buildQuery(params) {
return Object.keys(params)
.filter(key => params[key] !== '' && params[key] !== null && params[key] !== undefined)
.sort()
.map(key => `${key}=${String(params[key])}`)
.join('&');
}
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function signParams(params, secret) {
const plain = buildQuery(params);
return {
plain,
sign: md5(plain + secret)
};
}
const params = {
keyword: 'phone',
page: 1,
pageSize: 20,
timestamp: 1710000000,
nonce: 'abc123'
};
const secret = 'demo_secret';
const result = signParams(params, secret);
console.log('待签名字符串:', result.plain);
console.log('签名结果:', result.sign);
运行方式
node sign-demo.js
逐步验证清单
我建议你在复现时,不要一步到位直接跑最终请求,而是按下面顺序逐项确认:
1. 先验证原始输入
- 业务参数是否完整
- 公共参数是否一致
- 时间戳单位是秒还是毫秒
- nonce 是否需要随机生成
2. 再验证拼接结果
先打印待签名字符串:
console.log(plain);
确保它与浏览器调试时看到的字符串一致。
3. 再验证摘要输出
如果浏览器里生成的是小写十六进制:
- 你在 Node 里也要输出小写
- 不要误用 Base64
4. 最后再发请求
如果签名已对上,请求仍失败,再排查:
- Cookie
- Origin / Referer
- CSRF Token
- 本地存储中的设备标识
- Header 大小写差异
- 请求体序列化方式
常见坑与排查
这部分非常重要,很多“明明算法对了还是不行”的问题,都出在这里。
1. 时间戳单位不一致
常见两种:
- 秒:
1710000000 - 毫秒:
1710000000000
如果服务端校验时间窗口,单位错了会直接失败。
2. 排序规则理解错
有些站点不是简单 sort(),而是:
- ASCII 排序
- 只排第一层对象
- 排序后转大写键名
- 排除某些字段,如
sign本身
3. 参数值被编码过
比如浏览器里真正参与签名的是:
encodeURIComponent(value)
而你复现时直接拿原始中文字符串去算,结果必然不同。
4. JSON 序列化不一致
这类问题最隐蔽。比如:
JSON.stringify({a:1,b:2})
和
JSON.stringify({b:2,a:1})
在某些语言里生成顺序未必一致。
如果签名依赖原始 JSON 字符串,你必须还原一致的序列化过程。
5. Webpack 打包后函数名全变了
不要执着于找 sign() 这种“好看”的函数名。
现实里常见的是:
t(n(u(e)))
这时更有效的方法是:
- 从请求发送点向上追调用栈
- 看局部变量内容
- 对关键中间值加条件断点
6. 签名依赖运行时环境
例如:
document.cookielocalStorage.getItem('token')navigator.userAgentcanvas指纹window.location.href
这意味着你在纯 Node 环境复现时,可能需要补环境或手动传值。
7. 请求发送前被二次处理
有些框架会在请求拦截器里再次改参数,例如 Axios:
- request interceptor 中加 sign
- transformRequest 中重写 body
所以看到业务代码里没有签名,不代表真的没算。
一个实用的定位套路
如果你现在面对的是一个真实页面,我建议按这个顺序来:
- Network 确认签名字段
- 看 Initiator
- Hook XHR / Fetch
- 对
setRequestHeader或目标 URL 下断点 - 在断点现场查看局部变量
- 找到签名函数入口
- 抽取最小复现代码
- 先打印待签名字符串,再比对摘要结果
这套流程最大的优势是:
不依赖你一开始就能读懂混淆代码。
安全/性能最佳实践
这里补一句边界条件。本文讨论的是前端签名定位方法,适用于:
- 自有系统联调
- 安全研究
- 接口兼容分析
- 合法授权的测试环境
不应用于未授权的数据获取或绕过访问控制。
安全建议
1. 不要把真正的长期密钥放前端
如果前端能直接算签名,那么密钥迟早可能暴露。
更合理的方式是:
- 前端只持有短期令牌
- 核心签名在服务端完成
- 使用时效性强的临时凭证
2. 签名要结合时效与上下文
只校验固定摘要是不够的,建议至少加入:
- 时间戳
- nonce
- 用户会话信息
- 请求路径
- 请求体摘要
这样能降低重放风险。
3. 前端混淆不是安全边界
混淆、压缩、拆分模块只能增加分析成本,不能替代真正的鉴权设计。
性能建议
1. 避免重复计算大对象签名
如果请求体很大,频繁 JSON.stringify + hash 会有明显开销。
可以考虑:
- 仅签关键字段
- 做增量签名
- 避免不必要的深拷贝
2. 公共请求封装要可观测
建议在开发环境保留:
- 请求拦截日志
- 签名原文输出开关
- 错误码追踪
否则线上一旦签名失效,排查成本会很高。
调试现场示例:如何从断点一路追到算法
这里再给一个更贴近真实场景的例子。
假设你在断点处看到这样的代码:
var payload = {
keyword: "phone",
page: 1
};
var common = {
timestamp: Date.now(),
nonce: randomString(6)
};
var finalData = merge(payload, common);
var sign = h(finalData);
xhr.setRequestHeader("X-Sign", sign);
这时不要只看 h(finalData)。
你应该继续点进 h,确认它内部到底做了什么:
function h(data) {
var keys = Object.keys(data).sort();
var text = "";
for (var i = 0; i < keys.length; i++) {
text += keys[i] + "=" + data[keys[i]] + "&";
}
text += "k=" + "secret123";
return md5(text);
}
那么你要记录的不是“它用了 md5”,而是完整规则:
- 键名排序
- 拼接格式是
k=v& - 最后多拼了
k=secret123 - 没有过滤最后一个
&,而是继续拼 secret - 输出为 md5 小写 hex
这些细节才是复现成功的关键。
总结
前端签名逆向最怕两件事:
- 一上来就陷入混淆代码细节
- 只关注结果,不追踪输入和时序
更稳妥的方法是:
- 先抓包确认签名位置
- 再用 Hook 和断点拦住请求发送现场
- 顺着调用栈回溯签名函数
- 提取输入、排序、编码、拼接、摘要规则
- 用最小代码在浏览器或 Node 中复现
- 逐项对比待签名字符串,而不是只比最终 sign
如果你只能记住一个建议,那就是这个:
先打印“待签名字符串”,再谈算法复现。
因为很多时候,问题根本不在 MD5、SHA256 本身,而在签名之前的数据处理步骤。
把这个步骤吃透,绝大多数前端签名定位都会顺很多。