Web逆向实战:基于浏览器 DevTools 与 AST 还原前端签名算法的完整方法
前端签名算法这件事,很多人第一次碰到时都会有点懵:接口明明找到了,请求参数也看清楚了,甚至 Cookie、Header 都补齐了,结果服务端还是返回“签名错误”或者“请求非法”。
这类问题的难点不在“会不会发请求”,而在于:你得先把浏览器里那段混淆、压缩、动态拼装的签名逻辑找出来,并确认输入、执行路径、输出格式都还原正确。
这篇文章我不讲“纯理论”,而是按一个可落地的实战路径来带你走一遍:
- 用 Chrome DevTools 找到签名入口
- 沿调用链定位核心函数
- 用 AST 对混淆代码做结构化还原
- 在本地复现一个可运行的签名函数
- 用验证清单排查“看起来一样但结果不一样”的坑
说明:本文讨论的是前端分析与算法理解方法,适用于调试、自测、安全研究、接口联调等合法场景。不要用于未授权的数据抓取或绕过访问控制。
背景与问题
现在很多站点会把接口签名放在前端执行,常见形式包括:
- 请求参数排序后拼接,再做哈希
- 时间戳 + 随机串 + 固定盐值
- 对参数进行编码、位运算、字符替换
- Webpack 打包后藏在某个模块里
- 再配合混淆器,把变量名改成
a,b,_0x12ab
你会遇到几个典型问题:
- Network 里看到了请求,但不知道签名在哪算的
- 全局搜索
sign/token根本找不到 - 断点进去了,但调用栈层层封装,看不懂
- 把函数抠出来运行,结果和浏览器里不一致
- 算法里混入了环境依赖,比如
window、document、navigator、Date.now()
我自己一开始也经常卡在“已经找到函数了,但本地跑不通”。后来总结下来,一个高效流程是:
先动态定位,再静态还原,最后最小化复现。
这比一上来就硬啃压缩代码省力很多。
前置知识与环境准备
建议你至少具备这些基础:
- 会看浏览器 Network / Sources / Console
- 知道 JS 闭包、对象、数组、字符串常见操作
- 对 Webpack 模块、source map、混淆代码有基本概念
- 能运行 Node.js 脚本
环境准备
- Chrome 或 Edge 最新版
- Node.js 16+
- 一个代码编辑器,比如 VS Code
- AST 工具库:
@babel/parser@babel/traverse@babel/generator
安装示例:
npm init -y
npm install @babel/parser @babel/traverse @babel/generator
核心原理
在实战里,前端签名算法的定位通常依赖两条线并行推进:
- 动态分析线:DevTools 跟执行路径
- 静态分析线:AST 还原结构
一句话理解二者分工
- DevTools 解决的是:
“签名到底在哪一刻、哪一个函数里算出来的?” - AST 解决的是:
“这段压缩/混淆代码怎样变得可读、可抽取、可复现?”
整体流程图
flowchart TD
A[抓到目标请求] --> B[在 Network 查看请求参数/Header]
B --> C[定位 sign/timestamp/noncestr 等字段]
C --> D[在 Sources 中对 XHR/fetch 下断点]
D --> E[跟调用栈找到签名函数]
E --> F[记录输入参数与执行上下文]
F --> G[导出相关 JS 代码]
G --> H[使用 AST 做解混淆/重命名/常量折叠]
H --> I[最小化复现签名函数]
I --> J[对比浏览器结果验证]
背景与问题:签名算法通常藏在哪里
在真实站点中,签名逻辑常见于以下位置:
- 请求封装器,比如
request(),http(),axios.interceptors.request.use - 某个工具模块,比如
utils.js,encrypt.js,security.js - Webpack 模块数组中的一个匿名函数
- 运行时拼装代码,比如
eval、new Function - WASM 模块调用前后的 JS 包装层
所以,不要只搜“sign”这个词。很多时候它根本不叫 sign,而叫:
sx-sauthtoken_0x3f2a(...)- 甚至根本没字段名,而是最后统一塞进 Header
核心原理:如何用 DevTools 定位签名入口
方法 1:从目标请求反推
先打开 Network,找到目标接口,重点看:
- Query String Parameters
- Request Payload
- Request Headers
- 发起者 Initiator
你要先确认:
- 哪个字段最像签名?
- 是否和时间戳、随机串一起出现?
- 每次刷新页面是否变化?
方法 2:在 XHR/fetch 断点处拦截
在 DevTools 的 Sources 面板中,找到:
XHR/fetch Breakpoints- 添加接口关键字,比如
/api/search
请求发起时会断住,此时看:
- Call Stack
- Scope
- 当前函数参数
- 上一层调用者
这个步骤特别关键,因为你能直接看到“签名前最后一跳”。
方法 3:猴子补丁(Hook)快速打印
如果站点代码很复杂,我常用 Console 先 hook 一层:
(function () {
const oldFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch url]', args[0]);
console.log('[fetch options]', args[1]);
debugger;
return oldFetch.apply(this, args);
};
const oldOpen = XMLHttpRequest.prototype.open;
const oldSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return oldOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
console.log('[xhr]', this._method, this._url, body);
debugger;
return oldSend.call(this, body);
};
})();
这样做的好处是:不需要先看懂整个项目,就能先卡住请求发起点。
调用链定位示意
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant R as 请求封装器
participant S as 签名函数
participant N as Network 请求
U->>P: 点击按钮/翻页/搜索
P->>R: 组装参数
R->>S: 计算 sign
S-->>R: 返回签名字符串
R->>N: 发起请求
核心原理:为什么 AST 对还原签名算法很重要
很多前端签名逻辑不是“复杂”,而是“难读”。
比如原始代码可能长这样:
var _0xabc = function(_0x1, _0x2) {
return _0x1['split']('')['reverse']()['join']('') + _0x2;
};
你肉眼当然也能看,但当代码变成几千行、嵌套十几层、字符串字典到处跳转时,人工阅读效率会急剧下降。
AST 的价值在于:
- 把代码转成结构化树
- 批量改变量名
- 内联简单函数
- 还原字符串常量
- 删除迷惑性分支
- 找出真正参与签名的路径
一个简单的 AST 处理思路
- 解析源码为 AST
- 找到特定函数定义
- 替换混淆变量名
- 输出更易读代码
- 手工补充少量语义
实战:从一个简化案例还原签名算法
为了让流程完整,这里我用一个可运行的简化案例模拟实际场景。
假设前端发请求前执行了如下混淆代码:
var _0xarr = ['join', 'split', 'reverse', 'toString'];
function _0xmix(a, b) {
return a[_0xarr[1]]('')[_0xarr[2]]()[_0xarr[0]]('') + b;
}
function buildSign(params, ts) {
var keys = Object.keys(params).sort();
var str = '';
for (var i = 0; i < keys.length; i++) {
str += keys[i] + '=' + params[keys[i]] + '&';
}
str += 'ts=' + ts;
return _0xmix(str, 'SALT');
}
它做的事其实很简单:
- 参数按 key 排序
- 拼接
k=v& - 再加上
ts - 整体字符串反转
- 末尾追加
SALT
第一步:在浏览器里确认输入与输出
假设页面里最终发请求前,断点看到:
const params = {
q: 'phone',
page: 2
};
const ts = 1700000000;
const sign = buildSign(params, ts);
我们先在 Console 验证:
const params = { q: 'phone', page: 2 };
const ts = 1700000000;
function _0xmix(a, b) {
return a.split('').reverse().join('') + b;
}
function buildSign(params, ts) {
var keys = Object.keys(params).sort();
var str = '';
for (var i = 0; i < keys.length; i++) {
str += keys[i] + '=' + params[keys[i]] + '&';
}
str += 'ts=' + ts;
return _0xmix(str, 'SALT');
}
console.log(buildSign(params, ts));
第二步:用 AST 把迷惑性结构还原
下面这段 Node.js 脚本演示一个最小 AST 处理:把 _0xarr[1] 这种数组索引替换成真正字符串。
deobfuscate.js
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
const t = require('@babel/types');
const code = `
var _0xarr = ['join', 'split', 'reverse', 'toString'];
function _0xmix(a, b) {
return a[_0xarr[1]]('')[_0xarr[2]]()[_0xarr[0]]('') + b;
}
function buildSign(params, ts) {
var keys = Object.keys(params).sort();
var str = '';
for (var i = 0; i < keys.length; i++) {
str += keys[i] + '=' + params[keys[i]] + '&';
}
str += 'ts=' + ts;
return _0xmix(str, 'SALT');
}
`;
const ast = parser.parse(code);
let arrMap = null;
// 先收集 _0xarr 数组内容
traverse(ast, {
VariableDeclarator(path) {
const { id, init } = path.node;
if (
t.isIdentifier(id, { name: '_0xarr' }) &&
t.isArrayExpression(init)
) {
arrMap = init.elements.map(el => el.value);
}
}
});
// 再替换 _0xarr[n]
traverse(ast, {
MemberExpression(path) {
const { object, property, computed } = path.node;
if (
computed &&
t.isIdentifier(object, { name: '_0xarr' }) &&
t.isNumericLiteral(property) &&
arrMap
) {
const value = arrMap[property.value];
if (typeof value === 'string') {
path.replaceWith(t.stringLiteral(value));
}
}
}
});
const output = generate(ast, { retainLines: false }).code;
console.log(output);
运行:
node deobfuscate.js
可能得到输出:
var _0xarr = ['join', 'split', 'reverse', 'toString'];
function _0xmix(a, b) {
return a["split"]('')["reverse"]()["join"]('') + b;
}
function buildSign(params, ts) {
var keys = Object.keys(params).sort();
var str = '';
for (var i = 0; i < keys.length; i++) {
str += keys[i] + '=' + params[keys[i]] + '&';
}
str += 'ts=' + ts;
return _0xmix(str, 'SALT');
}
虽然这还不是“最终可读形态”,但已经比原始代码清晰很多了。
第三步:手工最小化复现签名函数
到这一步,不要急着把整份 JS 都搬到 Node 里。
正确做法是只保留与签名有关的最小依赖。
sign.js
function mix(str, salt) {
return str.split('').reverse().join('') + salt;
}
function buildSign(params, ts) {
const keys = Object.keys(params).sort();
let str = '';
for (const key of keys) {
str += `${key}=${params[key]}&`;
}
str += `ts=${ts}`;
return mix(str, 'SALT');
}
function buildSignedPayload(params) {
const ts = 1700000000;
const sign = buildSign(params, ts);
return {
...params,
ts,
sign
};
}
const result = buildSignedPayload({
q: 'phone',
page: 2
});
console.log(result);
运行:
node sign.js
第四步:逐步验证本地结果与浏览器一致
我建议每次都按这个顺序比对,不然很容易“整体不对但不知道差在哪”。
验证清单
- 原始参数对象是否一致
- 参数顺序是否一致
- 空值、布尔值、数组是否被特殊处理
- 时间戳单位是秒还是毫秒
- 拼接时是否多了
&或少了分隔符 - 是否做了
encodeURIComponent - 是否转成了 JSON 字符串
- 哈希前是否还有一层字符反转/替换/位运算
- 输出是小写还是大写
- 是否依赖浏览器环境字段
下面给一个很实用的“中间结果打印法”:
function debugBuildSign(params, ts) {
const keys = Object.keys(params).sort();
console.log('[keys]', keys);
let str = '';
for (const key of keys) {
const pair = `${key}=${params[key]}&`;
console.log('[pair]', pair);
str += pair;
}
str += `ts=${ts}`;
console.log('[plain]', str);
const reversed = str.split('').reverse().join('');
console.log('[reversed]', reversed);
const sign = reversed + 'SALT';
console.log('[sign]', sign);
return sign;
}
debugBuildSign({ q: 'phone', page: 2 }, 1700000000);
很多时候你会发现,错的不是“大逻辑”,而是一个小细节,比如:
- 你本地
page是数字,浏览器里其实先转成字符串了 - 你排序按对象插入顺序,浏览器实现里是显式
sort() - 浏览器里的
ts用的是Math.floor(Date.now()/1000)
一类典型复杂场景:签名函数嵌在模块加载体系里
现代前端项目里,经常是 Webpack/Vite 打包结果,签名函数可能藏在某个模块里,通过模块 ID 调用。
定位策略
- 在请求前断点,看调用栈里出现哪些模块文件
- 搜索请求 URL 片段、Header 名、常量盐值
- 观察是否经过拦截器、hooks、公共 SDK
- 在 Console 打印入口函数源码:
someFunc.toString()
- 如果有 source map,优先切回源代码看
模块调用关系示意
classDiagram
class Page {
+search()
}
class RequestClient {
+request(config)
+setHeaders()
}
class SignModule {
+buildCanonicalString(params)
+sign(input)
}
class CryptoHelper {
+reverse(str)
+hash(str)
}
Page --> RequestClient
RequestClient --> SignModule
SignModule --> CryptoHelper
常见坑与排查
这一节非常重要。很多人不是不会找,而是容易掉进“差一点就成功”的坑里。
1. 断点打到了请求处,但签名已经算完了
现象:
- 你在
fetch上断住了 - 看到请求 Header 里有 sign
- 但上层调用栈已经看不到签名细节
排查方法:
- 往上层函数逐步单步执行
- 搜索对
headers、params、data的写入 - 对
setRequestHeader或请求封装器下断点
2. 复制浏览器函数到 Node 后直接报错
常见原因:
- 依赖
window - 依赖
document.cookie - 依赖
navigator.userAgent - 依赖
location.href - 依赖 Web Crypto API
解决办法:
- 先 stub 最小环境
- 不要全量模拟浏览器,只补必要字段
示例:
global.window = global;
global.navigator = {
userAgent: 'Mozilla/5.0'
};
global.document = {
cookie: 'sessionid=demo'
};
global.location = {
href: 'https://example.com/page'
};
3. 结果只差一点,长度对但内容不对
这通常是编码问题。
重点检查:
encodeURIComponent是否执行过- UTF-8 / Base64 是否一致
- 哈希输入前是否转成字符串
- 十六进制输出大小写是否一致
4. 混淆代码里有“假函数”或“烟雾弹分支”
经验上,很多混淆器会插一些没用的函数和死分支,目的就是拖慢你阅读。
判断方法:
- 看函数返回值是否真正参与最终 sign
- 看分支条件是不是恒真/恒假
- 用 AST 做简单常量折叠
- 在运行时打印真实执行路径
5. 时间戳和随机数导致每次结果不同
这个坑最常见。你以为算法没还原对,其实只是输入不固定。
建议:
- 先把
Date.now()固定 - 把
Math.random()固定 - 浏览器和本地都用同一组参数重放
示例:
Date.now = () => 1700000000000;
Math.random = () => 0.123456789;
6. 算法并不在 JS,而在 WASM
现象:
- JS 里只看到一个很短的包装函数
- 核心逻辑在
WebAssembly.instantiate之后
处理建议:
- 先确认签名是否真的由 WASM 生成
- 观察 JS 与 WASM 的输入输出边界
- 优先抓“入参”和“出参”,不一定要先逆向整个 wasm 二进制
安全/性能最佳实践
这部分我想讲得更务实一点:还原签名算法不只是“跑通”,还要保证过程稳、代码可维护、不会误伤线上环境。
1. 只做最小必要 Hook
Hook 太多会带来两个问题:
- 页面行为异常
- 你自己把调用链扰乱了
建议优先 Hook:
fetchXMLHttpRequest.prototype.open/send- 必要时再 Hook
Object.defineProperty、eval、Function
2. 优先记录“边界数据”,不要盲目全量日志
最有价值的是这些信息:
- 签名前原始参数
- 时间戳、随机串
- 签名函数输入字符串
- 签名函数输出结果
而不是把整个页面所有脚本调用都打印出来。后者不仅噪声大,还会拖慢页面。
3. AST 改写要小步进行
我的建议是:
- 一次只做一种转换
- 每改一步就生成代码验证
- 不要一口气做十种替换
因为 AST 处理一旦错了,最后输出“看起来更清晰”,但语义已经变了,排查反而更痛苦。
4. 本地复现时尽量剥离页面环境
目标是得到这种结构:
- 一个纯函数
buildSign(params, ctx) - 一个上下文对象
ctx - 一个调用入口用于测试
而不是把整站 JS 原封不动搬来运行。越纯,越稳定,越容易自动化测试。
5. 建立测试样本集
至少保存 3 组浏览器抓到的样本:
- 固定参数 + 固定时间戳
- 含中文/特殊字符参数
- 含空值/数组/嵌套对象参数
然后本地逐个回放,确保结果一致。
一个更接近实战的本地验证模板
下面给一个可直接改造的模板,适合你把浏览器里抠出的逻辑塞进去做验证。
verify-sign.js
function canonicalize(params) {
return Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
}
function signCore(input, salt) {
return input.split('').reverse().join('') + salt;
}
function buildSign(params, ts, salt = 'SALT') {
const plain = `${canonicalize(params)}&ts=${ts}`;
return signCore(plain, salt);
}
function verifyCase() {
const params = {
page: 2,
q: 'phone'
};
const ts = 1700000000;
const plain = `${canonicalize(params)}&ts=${ts}`;
const sign = buildSign(params, ts);
console.log('plain =', plain);
console.log('sign =', sign);
return { plain, sign };
}
if (require.main === module) {
verifyCase();
}
module.exports = {
canonicalize,
signCore,
buildSign
};
这个模板的好处是:
canonicalize单独可测signCore单独可测buildSign负责组合流程- 后续替换成 md5/sha1/hmac/base64 也方便
逐步验证清单
如果你正在实战,我建议照着这份清单走,效率会高很多。
动态定位阶段
- 在 Network 中锁定目标请求
- 确认 sign 在 Query / Body / Header 中的位置
- 对目标 URL 设置 XHR/fetch 断点
- 记录断点处调用栈
- 找到签名前最后一个加工函数
静态还原阶段
- 导出相关 JS 代码片段
- 标记常量、盐值、时间戳、随机串来源
- 识别字符串字典、控制流平坦化、数组映射
- 用 AST 替换简单索引访问
- 对关键函数重命名
本地复现阶段
- 固定时间戳与随机数
- 保持参数顺序一致
- 保持编码方式一致
- 对比中间字符串
- 对比最终 sign
- 使用多组样本回归测试
总结
还原前端签名算法,最怕的是一开始就扎进混淆代码里硬读。更高效的方式其实是:
先用 DevTools 动态定位真实执行路径,再用 AST 把局部代码变得可读,最后在本地做最小化复现。
你可以把整套方法记成三句话:
- 找入口: 从请求出发,断在发起前
- 找核心: 沿调用栈定位真正参与签名的函数
- 找一致: 用固定输入对齐浏览器与本地输出
如果你已经是中级阶段,我特别建议把注意力放在这两个能力上:
- 记录边界数据的能力:知道该记什么,少走弯路
- 最小复现的能力:把一团项目代码缩成一个纯函数
最后给几个很实用的边界建议:
- 如果页面逻辑复杂,优先抓“签名前输入”和“签名后输出”,不要先追求 100% 看懂全站代码
- 如果本地结果总差一点,优先检查编码、排序、时间戳单位,而不是怀疑整个算法
- 如果遇到 WASM、动态
eval、强混淆控制流,不要硬拆全量代码,先卡住输入输出边界
能稳定复现签名,往往不是因为你“看懂了所有代码”,而是因为你抓住了最关键的那条数据流。这就是 DevTools + AST 在 Web 逆向里最有价值的地方。