背景与问题
做 Web 接口分析时,最常见也最“劝退”的一步,就是接口里多了几个一眼看不懂的参数,比如:
signtokennoncetimestampx-ssig
你在 Network 面板里明明能看到请求已经发出,也能拿到完整 URL、Header、Body,但一旦离开浏览器环境自己复现,请求就直接失败。原因通常不是接口路径错了,而是前端在发送请求前,动态生成了一组签名参数。
这类场景的难点在于:
- 签名逻辑常常被打散在多个 JS 文件里
- 代码可能经过压缩、混淆,变量名几乎不可读
- 签名生成可能依赖浏览器环境,例如:
windowdocumentnavigatorlocalStoragecanvas
- 有些站点会把加密逻辑塞进:
XMLHttpRequest.sendfetch- Axios 请求拦截器
- Webpack 模块内部函数
eval/new Function动态执行代码里
所以这篇文章不讲“大而空”的逆向理论,我直接带你走一条实战可落地的定位路径:
先从浏览器开发者工具拿到请求现场,再用 Hook 技术反向逼近签名生成点。
如果你之前的习惯是上来就全局搜
sign,那这篇文章的目标就是帮你把“猜”变成“定位”。
前置知识与环境准备
建议先具备以下基础:
- 会使用 Chrome DevTools
- 了解基本 HTTP 请求结构
- 看得懂 JavaScript 基础语法
- 知道
fetch、XMLHttpRequest、Axios 的区别
实验环境建议:
- Chrome 浏览器
- 一个带签名参数的目标页面
- 本地可注入调试脚本的方式:
- 控制台直接粘贴代码
- Snippets
- Tampermonkey
- 本地代理替换 JS
核心原理
要定位签名生成逻辑,核心不是“猜算法”,而是先回答下面三个问题:
- 签名在什么时候被拼进请求?
- 签名依赖哪些原始输入?
- 真正参与运算的函数在哪里?
一个高效的方法是从“结果”倒推“过程”:
- 已知结果:Network 里的请求参数
- 观察时机:请求发起前的 JS 调用链
- 插入 Hook:拦截关键 API
- 回溯调用栈:找到签名构造函数
从请求生命周期理解定位思路
flowchart TD
A[页面触发业务动作] --> B[组装业务参数]
B --> C[生成时间戳/随机数]
C --> D[调用签名函数]
D --> E[拼接 Header 或 Query]
E --> F[fetch/XHR 发起请求]
F --> G[服务端验签]
很多人卡在 D,但真正好定位的切入点通常是 E 和 F。因为:
- D 可能被混淆
- E/F 必须接触明文请求参数
- 在 E/F 处 Hook,能拿到最终 payload 和调用栈
常用定位入口
我一般按下面顺序来排:
- Network 面板看请求
- 参数放在 Query、Body 还是 Header
- 全局搜索关键字段
- 搜
sign - 搜请求路径片段
- 搜自定义 Header 名
- 搜
- Hook 网络发送层
fetchXMLHttpRequest.open/send- Axios 拦截器
- Hook 可疑加密原语
JSON.stringifybtoaatobCryptoJS.MD5CryptoJS.SHA256window.crypto.subtle.digest
- 结合调用栈回溯
console.trace()debugger- DevTools Call Stack
一套实战定位流程
这一部分按“我真的在页面上操作”的方式来讲。
第 1 步:先在 Network 里确认签名落点
打开 DevTools,切到 Network,重新操作页面,让目标请求发出。重点看:
- 请求 URL 中有没有
sign - Form Data / Request Payload 里有没有签名字段
- Request Headers 里是否存在自定义签名头
例如你看到:
POST /api/user/info
Headers:
x-sign: 8f4a0d...
x-ts: 1735200000
Body:
{"uid":12345}
这时要先记下:
- 签名字段名:
x-sign - 关联字段:
x-ts - 接口路径:
/api/user/info
这一步看似简单,但很关键。因为后面的 Hook,必须围绕这些特征展开。
第 2 步:全局搜索“稳定特征”,不要只搜 sign
在 Sources -> Search 里优先搜索这些内容:
- 请求路径,如
/api/user/info - Header 名,如
x-sign - 参数名,如
x-ts - 固定字符串前缀
- 某些业务字段名
为什么不只搜 sign?因为实际项目里:
- 变量可能叫
s - Header 可能在对象字面量里动态赋值
- 字段名可能被字符串拼接出来
我自己踩过一个坑:站点把 header key 写成了:
headers[prefix + "-sign"] = value;
直接搜 x-sign 根本搜不到,最后是通过 Hook fetch 才定位到。
第 3 步:Hook fetch,抓最终请求参数和调用栈
现代站点大量使用 fetch。先上一个最实用的 Hook:
(() => {
const rawFetch = window.fetch;
window.fetch = async function(...args) {
const [input, init] = args;
console.group("=== fetch hook ===");
console.log("input:", input);
console.log("init:", init);
console.trace("fetch call stack");
console.groupEnd();
debugger; // 需要时打开,直接中断到发请求前
return rawFetch.apply(this, args);
};
console.log("[hook] fetch installed");
})();
看什么
执行后重新触发请求,你通常能看到:
- URL
- method
- headers
- body
- 调用栈
如果签名在 headers 或 body 里已经出现,那么你已经拿到了最关键的信息:
是谁在调用 fetch 的前一刻,完成了签名拼装。
第 4 步:Hook XHR,兼容老站点和部分框架封装
有些站点不用 fetch,而是 XMLHttpRequest。继续补一个:
(() => {
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this._hookMethod = method;
this._hookUrl = url;
return rawOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
if (!this._hookHeaders) this._hookHeaders = {};
this._hookHeaders[key] = value;
return rawSetRequestHeader.call(this, key, value);
};
XMLHttpRequest.prototype.send = function(body) {
console.group("=== xhr hook ===");
console.log("method:", this._hookMethod);
console.log("url:", this._hookUrl);
console.log("headers:", this._hookHeaders);
console.log("body:", body);
console.trace("xhr send stack");
console.groupEnd();
debugger;
return rawSend.call(this, body);
};
console.log("[hook] xhr installed");
})();
这样无论目标站点走哪种发送方式,你基本都能兜住。
第 5 步:发现签名字段后,反向 Hook 可疑加密函数
如果你已经知道签名长得像 MD5 / SHA / Base64,就可以顺着特征继续逼近。
Hook btoa
(() => {
const raw = window.btoa;
window.btoa = function(str) {
console.group("=== btoa hook ===");
console.log("input:", str);
console.trace("btoa stack");
console.groupEnd();
debugger;
return raw.call(this, str);
};
})();
Hook JSON.stringify
很多签名逻辑会先把对象序列化:
(() => {
const raw = JSON.stringify;
JSON.stringify = function(...args) {
console.group("=== JSON.stringify hook ===");
console.log("args:", args);
console.trace("stringify stack");
console.groupEnd();
return raw.apply(this, args);
};
})();
Hook Web Crypto
(() => {
if (!window.crypto || !window.crypto.subtle) return;
const raw = window.crypto.subtle.digest;
window.crypto.subtle.digest = function(...args) {
console.group("=== subtle.digest hook ===");
console.log("algorithm:", args[0]);
console.log("data:", args[1]);
console.trace("digest stack");
console.groupEnd();
debugger;
return raw.apply(this, args);
};
})();
如果页面使用第三方库,比如 CryptoJS.MD5,也可以这样 Hook:
(() => {
if (!window.CryptoJS || !window.CryptoJS.MD5) {
console.log("CryptoJS.MD5 not found");
return;
}
const raw = window.CryptoJS.MD5;
window.CryptoJS.MD5 = function(...args) {
console.group("=== CryptoJS.MD5 hook ===");
console.log("args:", args);
console.trace("md5 stack");
console.groupEnd();
debugger;
return raw.apply(this, args);
};
})();
从 Hook 到定位源码:一条完整路径
下面用一个简化案例串起来。
假设请求长这样:
POST /api/order/list
Headers:
x-ts: 1735200000
x-sign: c0ffee123456
Body:
{"page":1,"size":20}
我们先 Hook fetch,看到最终 headers:
{
"x-ts": "1735200000",
"x-sign": "c0ffee123456"
}
调用栈里出现:
request.js:formatted:120api.js:formatted:45order.js:formatted:88
这时进入 request.js 断点位置,往上一两行通常能看到类似:
const ts = Date.now().toString();
const sign = makeSign(url, body, ts);
headers["x-ts"] = ts;
headers["x-sign"] = sign;
return fetch(url, { method, headers, body });
然后继续跟进 makeSign,就可能见到:
function makeSign(url, body, ts) {
const payload = url + "|" + body + "|" + ts + "|" + secret;
return md5(payload);
}
到这里,签名原始输入就全部拿到了。
这个过程可以抽象成下面的图:
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名函数
participant N as fetch/XHR Hook
participant R as 服务端
U->>P: 点击查询/翻页
P->>S: 传入 url/body/timestamp
S-->>P: 返回 sign
P->>N: 发起请求并附带 sign
N-->>P: 输出 headers/body/调用栈
P->>R: 正式请求
实战代码(可运行)
这一节给一套“可直接粘贴到控制台”的调试工具箱。它的目的不是替代专业调试器,而是帮助你快速筛出关键点。
1. 通用网络 Hook 工具
(function installNetHook() {
if (window.__netHookInstalled__) {
console.log("[hook] already installed");
return;
}
window.__netHookInstalled__ = true;
// fetch hook
if (window.fetch) {
const rawFetch = window.fetch;
window.fetch = async function(...args) {
const [input, init] = args;
console.group("%c[fetch]", "color: green;");
console.log("url/input:", input);
console.log("init:", init);
try {
if (init && init.headers) {
console.log("headers:", init.headers);
}
if (init && init.body) {
console.log("body:", init.body);
}
} catch (e) {
console.warn("read fetch init failed:", e);
}
console.trace("fetch trace");
console.groupEnd();
return rawFetch.apply(this, args);
};
}
// xhr hook
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this.__method = method;
this.__url = url;
this.__headers = {};
return rawOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function(k, v) {
this.__headers[k] = v;
return rawSetRequestHeader.call(this, k, v);
};
XMLHttpRequest.prototype.send = function(body) {
console.group("%c[xhr]", "color: blue;");
console.log("method:", this.__method);
console.log("url:", this.__url);
console.log("headers:", this.__headers);
console.log("body:", body);
console.trace("xhr trace");
console.groupEnd();
return rawSend.call(this, body);
};
console.log("[hook] net hook installed");
})();
2. 签名特征过滤器
如果页面请求很多,日志会刷屏。可以加一个过滤器,只盯某个接口:
(function installFilteredFetchHook() {
const keyword = "/api/order/list";
const rawFetch = window.fetch;
window.fetch = async function(...args) {
const [input, init] = args;
const url = typeof input === "string" ? input : (input && input.url) || "";
if (url.includes(keyword)) {
console.group("%c[target fetch]", "color: red;");
console.log("url:", url);
console.log("init:", init);
console.trace("target trace");
console.groupEnd();
debugger;
}
return rawFetch.apply(this, args);
};
console.log("[hook] filtered fetch installed:", keyword);
})();
3. 一个本地可运行的签名演示页
如果你想先在本地理解整个思路,可以用下面这段 HTML。它模拟“前端生成签名再请求”的过程。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>sign demo</title>
</head>
<body>
<button id="btn">发送请求</button>
<script>
function fakeMd5(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return "m" + Math.abs(hash).toString(16);
}
function makeSign(url, body, ts) {
const secret = "demo_secret";
const raw = url + "|" + body + "|" + ts + "|" + secret;
return fakeMd5(raw);
}
async function requestApi() {
const url = "/api/order/list";
const bodyObj = { page: 1, size: 20 };
const body = JSON.stringify(bodyObj);
const ts = String(Date.now());
const sign = makeSign(url, body, ts);
console.log("最终签名:", sign);
return fetch("https://httpbin.org/anything", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-ts": ts,
"x-sign": sign
},
body
});
}
document.getElementById("btn").addEventListener("click", requestApi);
</script>
</body>
</html>
你可以先把上面的网络 Hook 注入这个页面,观察一遍请求发送过程,再去分析真实站点,思路会更顺。
逐步验证清单
很多人会在“好像看到了”这一步停住,但真正可复现的分析,最好一项项验证。
验证 1:签名字段是否稳定存在
重复触发 3~5 次请求,观察:
- 参数名是否固定
- 是否每次都带上
- Header / Body 位置是否变化
验证 2:签名是否与时间戳绑定
手动比对:
- 改变
timestamp - 业务参数不变
- 看
sign是否变化
如果强绑定时间戳,说明签名输入里通常包含 ts。
验证 3:签名是否与 Body 字段顺序有关
这一点非常常见。很多签名是对字符串做摘要,而不是对对象语义做摘要。
例如下面两段对象语义一样,但字符串不同:
JSON.stringify({a:1, b:2});
JSON.stringify({b:2, a:1});
如果结果字符串不同,签名就可能不同。
验证 4:签名逻辑是否有环境依赖
检查是否读取了:
navigator.userAgentlocation.hrefdocument.cookielocalStoragesessionStorage
如果有,就意味着你在浏览器外复现时,要补环境。
可以用下面这张图理解验证顺序:
flowchart LR
A[抓到最终请求] --> B[确认 sign 位置]
B --> C[确认关联字段]
C --> D[Hook 发送层]
D --> E[Hook 加密原语]
E --> F[定位签名函数]
F --> G[验证输入输出]
G --> H[判断是否可脱离浏览器复现]
常见坑与排查
这一节是我觉得最有价值的部分。因为真正花时间的,往往不是“不会 Hook”,而是 Hook 了但结论不可靠。
1. Hook 太晚,错过初始化阶段
有些站点在页面加载初期就完成:
- token 初始化
- wasm 加载
- 加密函数缓存
- 请求实例封装
你如果在操作按钮之后才注入 Hook,可能已经错过了关键逻辑。
排查办法
- 尽量在页面刚加载时就注入
- 用 Snippets + 手动刷新后立即执行
- 必要时用 Tampermonkey 提前注入
2. 被框架封装,表面看不到真实请求参数
例如 Axios 会在请求拦截器里改 headers。你在业务代码里看到的对象,可能还不是最终结果。
排查办法
优先 Hook 更底层的位置:
fetchXMLHttpRequest.send- Axios
interceptors.request.use
如果页面暴露了 Axios 实例,也可以这样挂:
if (window.axios) {
window.axios.interceptors.request.use(function(config) {
console.group("[axios request]");
console.log(config.url);
console.log(config.headers);
console.log(config.data);
console.trace();
console.groupEnd();
return config;
});
}
3. 代码被混淆,全局搜索不到关键字
压缩后可能出现:
a[b+c]=d(e,f,g)
这时候强行搜源码效率很低。
排查办法
- 从 Hook 的调用栈跳回源码位置
- 使用 Pretty Print 格式化
- 在可疑函数入口打断点
- 观察局部变量的值,而不是纠结变量名
4. 签名在 eval 或 new Function 中生成
有些站点为了增加分析成本,会动态执行代码。
排查办法
Hook 动态执行入口:
(() => {
const rawEval = window.eval;
window.eval = function(code) {
console.group("=== eval hook ===");
console.log(code);
console.trace("eval stack");
console.groupEnd();
return rawEval.call(this, code);
};
const RawFunction = window.Function;
window.Function = function(...args) {
console.group("=== Function hook ===");
console.log("args:", args);
console.trace("Function stack");
console.groupEnd();
return RawFunction.apply(this, args);
};
})();
这里要注意,Hook
Function有概率影响页面行为,建议短时使用。
5. 签名并不是单纯哈希,而是“排序 + 拼接 + 编码 + 摘要”
这是最常见的误判来源。你看到最终函数是 md5(...),不代表签名逻辑只有 MD5。
很多真实流程其实是:
- 参数排序
- 去空值
- 拼接固定分隔符
- 拼接 secret
- URL 编码或 Base64
- 再做 MD5/SHA
排查办法
不要只盯“最后一跳”,而要把进入摘要函数前的原始字符串抓出来。
6. 误把响应验签逻辑当成请求签名逻辑
页面中可能同时存在:
- 请求签名
- 响应解密
- 响应验签
- 上报埋点签名
排查办法
始终围绕一个具体接口跟踪,确认:
- 请求发出前谁改了 Header/Body
- 这个函数是否只在目标接口发送前触发
安全/性能最佳实践
这部分不是“站在道德高地说教”,而是帮你把调试过程做得更稳,不容易把页面搞崩。
1. Hook 要最小化、可撤销
不要一上来 Hook 十几个 API。建议:
- 先 Hook
fetch/XHR - 再按需 Hook
JSON.stringify、btoa、digest - 记录原始函数,必要时恢复
恢复示例:
const rawFetch = window.fetch;
// ...hook
window.fetch = rawFetch;
2. 避免在高频函数里打印超大对象
例如 JSON.stringify、Array.prototype.push 这类函数可能调用极多。
如果每次都 console.trace(),页面会明显卡顿。
建议:
- 增加 URL 过滤
- 增加次数限制
- 只在命中关键词时
debugger
示例:
let hit = 0;
const maxHit = 5;
const raw = JSON.stringify;
JSON.stringify = function(...args) {
const text = args[0] && typeof args[0] === "object" ? Object.keys(args[0]).join(",") : "";
if (text.includes("sign") && hit < maxHit) {
hit++;
console.log("hit stringify:", args[0]);
debugger;
}
return raw.apply(this, args);
};
3. 不要在未知站点随意执行来源不明的调试脚本
这是很实际的安全问题。你在控制台里执行的脚本,拥有和页面几乎同级的能力。
尤其不要直接运行不明来源的“万能 Hook 脚本”。
建议:
- 自己理解每一行代码再执行
- 在测试环境优先复现
- 不在生产后台或敏感账号环境下随便注入
4. 明确边界:前端可见,不代表可任意复用
即使你已经定位到签名逻辑,也可能仍受限于:
- 动态 token
- 设备指纹
- 服务端风控
- 短时效密钥
- 登录态绑定
也就是说,定位签名函数 ≠ 一定能在浏览器外完整复现。
中级学习者最容易在这里产生错觉:算法明明看到了,为什么还不通?
通常不是算法错,而是上下文没补齐。
一个更贴近真实项目的定位策略
如果你面对的是 Webpack/Vite 打包后的大型项目,我建议按下面这个优先级执行:
策略 A:先抓“最终请求现场”
目标:
- 找到 sign 在哪
- 找到谁发的请求
- 找到调用栈
工具:
- Network
fetch/XHRHook
策略 B:再抓“摘要前的明文”
目标:
- 找到真正输入摘要函数的字符串
- 确认排序、拼接、编码细节
工具:
- Hook
JSON.stringify - Hook
btoa - Hook
CryptoJS.MD5/SHA - Hook
crypto.subtle.digest
策略 C:最后才读混淆源码
目标:
- 补全上下文
- 确认依赖项
- 提炼可复现逻辑
工具:
- Pretty Print
- Scope 变量观察
- Blackbox 无关脚本
很多时候,前两步做到位,源码其实只需要读 20% 左右。
总结
定位前端签名参数生成逻辑,最有效的方法不是“猜算法”,而是遵循这条路径:
- 先在 Network 确认签名出现的位置
- 从请求发送层 Hook,拿到最终参数和调用栈
- 再 Hook 加密原语,抓摘要前的输入
- 最后回到源码,补齐排序、拼接、编码和环境依赖
如果你只能记住一句话,我建议记这个:
不要从混淆代码里盲找 sign,要从请求发出前的那一刻反推。
这是我自己做 Web 逆向时最常用、也最省时间的一套办法。它的优点是:
- 不依赖特定框架
- 对混淆代码也有效
- 能快速缩小排查范围
但也要明确边界:
- 如果签名依赖浏览器环境、设备指纹、WASM、动态密钥,定位后仍需补环境
- 如果 Hook 过多,会影响页面性能,甚至干扰原逻辑
- 如果目标站点有安全防护,调试过程也要注意注入时机和副作用
最后给你一个可执行建议:
下次碰到签名接口时,不要急着搜 sign,先做一个最小版 fetch/XHR Hook。
只要你能看到“请求发出去前最后一刻发生了什么”,后面的分析通常就顺了。