Web逆向实战:基于浏览器开发者工具定位并还原前端加密签名生成流程
很多同学第一次做 Web 逆向时,卡住的点并不是“不会写代码”,而是不知道从哪里下手:页面请求里多了个 sign、token、x-s、signature 参数,服务端一校验就报错;明明参数长得像加密值,但前端代码被混淆、压缩、拆包后,想定位生成逻辑像在草堆里找针。
这篇文章我不讲“玄学猜测”,而是带你走一条可复现、可验证、可落地的路径:基于浏览器开发者工具,定位签名生成入口,观察参与参数,还原签名算法,并在本地用代码复现。文章偏实战,适合已经懂一点 JavaScript、浏览器调试和抓包的中级读者。
说明:本文讨论的是学习前端安全机制、接口调试与合法授权下的分析方法。请仅在授权测试、个人学习、企业自有系统排障等合法场景下使用。
背景与问题
我们先抽象一下常见场景:
前端发起请求时,请求头或 Query 参数里会带上一段签名,比如:
sign=4c8f...x-signature: abcdef...token: eyJ...t=时间戳nonce=随机串
很多接口不是简单校验某个固定值,而是将:
- 请求路径
- 请求方法
- 业务参数
- 时间戳
- nonce
- 用户态 token
- 某个内置 secret 或盐值
按约定顺序拼接后,再经过:
MD5SHA1/SHA256HmacSHA256AES/RSA混合- 自定义字符变换 / Base64 / URLSafe 编码
最终生成签名。
问题是:你拿到的是最终请求,不知道中间过程。而 Web 逆向的关键恰恰不是“猜出结果”,而是还原生成流程。
前置知识与环境准备
开始前,建议你准备这些工具:
- Chrome / Edge 浏览器
- 开发者工具(DevTools)
- 一个支持格式化 JS 的编辑器,如 VS Code
- Node.js 18+
- 可选:Charles / Fiddler / mitmproxy(辅助观察请求)
- 可选:SourceMap Explorer 或 AST 工具(处理复杂混淆)
你至少需要掌握:
- DevTools 的
Network / Sources / Console - JS 基本语法
- Promise、XHR、Fetch 的调用方式
- 基础哈希概念:MD5、SHA 系列、HMAC
背景与问题:为什么优先从浏览器开发者工具切入
不少人一上来就把整个站点 JS 全部下载下来“静态看代码”。这不是不行,但成本很高。实际项目里,前端代码往往:
- 打包为多个 chunk
- 文件名哈希化
- 混淆变量名
- 有 sourcemap 但生产环境关了
- 动态加载模块
- 使用 Webpack runtime 包装
这时,浏览器开发者工具的优势是你能看到“运行时”:
- 哪个请求带了签名
- 请求发起栈在哪
- 哪段代码在真正执行
- 运行时变量是什么
- 调用顺序是什么
换句话说,DevTools 让你少走很多弯路。
核心原理
要还原前端签名,核心不是盯着“加密算法”四个字,而是拆成 4 个问题:
- 签名在哪生成?
- 参与签名的数据有哪些?
- 这些数据按什么顺序、什么格式拼接?
- 最后用了什么算法输出?
把这 4 个问题搞清楚,复现就不难了。
一个典型签名链路
flowchart TD
A[用户触发请求] --> B[业务参数收集]
B --> C[补充时间戳/nonce]
C --> D[参数排序/序列化]
D --> E[拼接盐值或密钥]
E --> F[哈希或加密]
F --> G[写入请求头/Query]
G --> H[发送到服务端]
常见签名参与项
实际中最常见的是以下几类:
- 业务参数:如
page=1&size=20 - 时间戳:防重放
- 随机串 nonce:增加请求唯一性
- 请求路径:例如
/api/user/list - HTTP 方法:GET / POST
- body 摘要:POST JSON 场景常见
- 固定盐值:写死在前端,或者拆散后再拼
- 用户态信息:token、uid、session
常见输出形式
- 32 位 hex:通常让人联想到 MD5
- 40 位 hex:常见 SHA1
- 64 位 hex:常见 SHA256/HmacSHA256
- Base64:常见对二进制摘要做编码
- URLSafe Base64:把
+ / =做替换 - 长串 JSON / JWE 样式:可能是更复杂的 token 结构
一套稳定的定位思路
这里给你一套我实战里经常用的流程,尤其适合“看起来很乱”的前端项目。
flowchart LR
A[Network 观察异常参数] --> B[定位请求发起位置]
B --> C[在 XHR/Fetch 断点处拦截]
C --> D[回溯调用栈]
D --> E[锁定签名函数]
E --> F[观察入参与中间值]
F --> G[本地复现]
G --> H[对比线上结果校验]
实战示例:一步步定位并还原签名
下面我们用一个可运行的简化案例来演示完整思路。示例不是某个真实站点,而是把真实项目里常见特征抽出来,方便你练手。
目标请求
前端发出的请求是:
GET /api/user/list?page=1&size=20&t=1710000000000&nonce=ab12cd34&sign=xxxx
我们要搞清楚 sign 是怎么来的。
第一步:在 Network 面板确认异常参数
打开浏览器开发者工具,进入 Network 面板,执行页面操作后找到目标请求。
重点观察:
- Query String Parameters
- Request Headers
- Form Data / Payload
- Initiator
你会发现这个请求里多了:
tnoncesign
这说明签名大概率依赖时间戳和随机串,而不是一个固定值。
观察重点
sign是否每次请求都变化?- 只改一个业务参数时,
sign是否同步变化? - 刷新页面后,
nonce是否变化? - 相同参数、不同时间下,是否只有
t/sign变化?
如果答案是“是”,那么基本可以判断:
签名依赖请求参数 + 时间戳 + 随机值。
第二步:从请求发起点反查代码
在 Network 里点开该请求,查看 Initiator。很多时候能看到:
- 某个打包后的 js 文件
- 某个函数调用栈
fetchXMLHttpRequest.send
如果看不到足够信息,建议直接在 Sources 面板中开启以下断点:
XHR/fetch Breakpoints- 关键字可填:
/api/user/list - 或者直接断在所有 XHR / fetch 上
这样请求发起前,代码会停住。
断住后看什么?
停住时,不要急着“单步到底”,先看:
Call StackScope- 当前函数入参
- 局部变量里是否已有
sign、nonce、timestamp
很多情况下,你会看到类似这样的调用链:
requestUserList -> buildParams -> makeSign -> fetch
如果变量名被混淆了,也没关系。你只关心一件事:哪个函数返回的值最终塞进了 sign 字段。
第三步:锁定签名函数
假设你在断点里看到这样一段逻辑(这里用可读代码模拟):
function requestUserList(page, size) {
const params = {
page,
size,
t: Date.now(),
nonce: randomString(8)
};
params.sign = makeSign("/api/user/list", params);
return fetch("/api/user/list?" + new URLSearchParams(params));
}
那显然,关键就在 makeSign。
继续进入 makeSign,看到:
function makeSign(path, params) {
const keys = Object.keys(params).sort();
const query = keys.map(k => `${k}=${params[k]}`).join("&");
const raw = `${path}?${query}#k9JmVqP3`;
return md5(raw);
}
到这里,签名链路就很清晰了:
- 参数对象取 key
- 按字典序排序
- 拼成
k=v&k=v - 加上路径
- 末尾拼 secret:
#k9JmVqP3 - 对整个字符串做
md5
用时序图理解一次完整请求
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant N as Network请求
participant B as 服务端
U->>P: 点击“查询”
P->>P: 组装 page/size
P->>P: 生成 t 和 nonce
P->>S: makeSign(path, params)
S->>S: 排序/拼接/MD5
S-->>P: 返回 sign
P->>N: 发起 fetch/XHR
N->>B: 携带 sign 请求
B-->>N: 校验通过并响应
N-->>P: 返回业务数据
第四步:验证“不是看起来像”,而是真的对
做逆向最怕“看起来像对了,其实只抄到表面”。所以必须做逐步验证。
验证清单
你可以按下面顺序验证:
- 固定
page=1,size=20,t=1710000000000,nonce=ab12cd34 - 观察浏览器里最终
sign - 在 Console 里手动调用
makeSign - 在本地 Node.js 里复现
- 比较结果是否完全一致
- 改变
page再试一次 - 调换参数顺序再试一次
- 去掉 secret 再试一次,确认结果会不同
浏览器 Console 验证
如果页面作用域里还能直接访问到函数,可以在 Console 中尝试:
makeSign("/api/user/list", {
page: 1,
size: 20,
t: 1710000000000,
nonce: "ab12cd34"
});
如果页面里拿不到这个函数,也可以把断点里看到的关键逻辑手抄出来执行。
实战代码(可运行)
下面给出一个完整的 Node.js 版本复现代码。你可以直接保存为 sign_demo.js 运行。
1)签名生成代码
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function buildSign(path, params, secret = "#k9JmVqP3") {
const keys = Object.keys(params).sort();
const query = keys.map(k => `${k}=${params[k]}`).join("&");
const raw = `${path}?${query}${secret}`;
return md5(raw);
}
function buildRequestParams(page, size) {
const params = {
page,
size,
t: 1710000000000,
nonce: "ab12cd34"
};
params.sign = buildSign("/api/user/list", params);
return params;
}
const params = buildRequestParams(1, 20);
console.log("params =", params);
console.log("query =", new URLSearchParams(params).toString());
运行:
node sign_demo.js
2)如果站点用的是 HMAC-SHA256
不少站点并不是单纯 md5(raw),而是:
const crypto = require("crypto");
function hmacSha256(text, secret) {
return crypto
.createHmac("sha256", secret)
.update(text, "utf8")
.digest("hex");
}
function buildSign(path, params, secret = "my_secret_key") {
const keys = Object.keys(params).sort();
const query = keys.map(k => `${k}=${params[k]}`).join("&");
const raw = `${path}|${query}`;
return hmacSha256(raw, secret);
}
const params = {
page: 1,
size: 20,
t: 1710000000000,
nonce: "ab12cd34"
};
console.log(buildSign("/api/user/list", params));
3)浏览器端等价实现
如果你想在浏览器 Console 中快速验证,可用 Web Crypto API 实现 SHA-256(注意:Web Crypto 不直接提供 MD5)。
async function sha256Hex(text) {
const encoder = new TextEncoder();
const data = encoder.encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
async function buildSign(path, params) {
const secret = "#k9JmVqP3";
const keys = Object.keys(params).sort();
const query = keys.map(k => `${k}=${params[k]}`).join("&");
const raw = `${path}?${query}${secret}`;
return await sha256Hex(raw);
}
(async () => {
const params = {
page: 1,
size: 20,
t: 1710000000000,
nonce: "ab12cd34"
};
console.log(await buildSign("/api/user/list", params));
})();
一个更贴近真实项目的定位技巧
真实项目往往不会把 makeSign 这样明晃晃地写出来。更常见的是:
- 函数名被混淆成
a,b1,_0x3f2a md5被封装多层secret被拆开保存在数组中- 请求经 Axios 拦截器统一处理
这时建议你换一个入口:从请求框架层向回追。
Axios 场景的典型位置
你可以优先搜索这些关键词:
interceptors.request.useheaderstransformRequestURLSearchParamscryptomd5shasignnoncetimestamp
如果全局搜索不到明文关键词,就在请求断点停住后,看配置对象:
config.url
config.params
config.data
config.headers
签名很多时候就是在这里被挂进去的。
一个简化版 Axios 拦截器示例
import axios from "axios";
import crypto from "crypto";
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function signParams(url, params) {
const keys = Object.keys(params).sort();
const query = keys.map(k => `${k}=${params[k]}`).join("&");
return md5(`${url}?${query}#k9JmVqP3`);
}
const client = axios.create();
client.interceptors.request.use(config => {
const params = {
...(config.params || {}),
t: Date.now(),
nonce: Math.random().toString(36).slice(2, 10)
};
params.sign = signParams(config.url, params);
config.params = params;
return config;
});
如果你在断点中看到类似结构,基本就接近答案了。
常见坑与排查
这部分很重要。我自己踩过不少坑,很多时候不是算法没看懂,而是细节没对齐。
1. 参数顺序不一致
签名最常见的问题就是顺序。
浏览器里是:
Object.keys(params).sort()
你本地却按对象原始顺序拼接,那结果必然不一样。
排查建议
把用于签名的原始字符串直接打印出来,逐字符对比:
console.log(raw);
不要只对比最终 sign。
2. URL 编码差异
有些站点签名前用的是原始值,有些用的是 encodeURIComponent 后的值。
比如空格可能是:
%20+
中文、特殊字符更容易出问题。
排查建议
确认这几点:
- 是先拼接再编码,还是先编码再拼接
- body JSON 是否做了
JSON.stringify - 数组是否用逗号拼接
- 对象是否先序列化
3. 时间戳单位搞错
常见有两种:
- 秒级:
1710000000 - 毫秒级:
1710000000000
差 1000 倍,签名必错。
排查建议
在浏览器里直接打印参与签名的时间戳,别靠猜。
4. secret 不是明文常量
有些代码会把 secret 拆成多段,例如:
const s = ["k9", "Jm", "Vq", "P3"].join("");
甚至做字符位移、数组倒序、Base64 解码后再拼。
排查建议
不要只看静态字符串,要看运行时最终值。
断点停在哈希调用前,查看传入的 raw 和 secret 最可靠。
5. 哈希算法判断错了
很多人看到 32 位 hex 就先入为主认定是 MD5,但也可能是:
- 截断后的 SHA
- 自定义编码结果
- 多次 hash 后截位
排查建议
优先看实际调用的 API:
CryptoJS.MD5CryptoJS.SHA256crypto.subtle.digestcreateHash("md5")createHmac("sha256", key)
别只靠长度猜。
6. 请求体参与签名,但你漏了
POST 请求特别容易踩这个坑。尤其是 JSON body:
{"page":1,"size":20}
签名可能是对整个 body 字符串求摘要,再和其他字段一起拼接。
排查建议
重点看:
config.dataJSON.stringify(data)transformRequestContent-Type
如果 body 参与签名,字段顺序、空格、转义都可能影响结果。
7. 代码有反调试或动态改写
一些前端会:
- 检测 DevTools 打开
- 重写
Function.prototype.toString - 动态生成函数
- 通过
eval、new Function执行代码
排查建议
- 在关键 API 上打断点,而不是只看源码
- 观察运行时变量
- 必要时重写关键函数做日志输出
- 把混淆代码“跑起来再抓中间值”
一种很实用的“插桩”办法
当代码太绕、不好单步时,我常用的方法是对关键函数做插桩。
比如页面用了 CryptoJS.MD5,你可以在 Console 里临时包一层日志。
浏览器插桩示例
(function () {
if (!window.CryptoJS || !CryptoJS.MD5) {
console.log("CryptoJS.MD5 not found");
return;
}
const original = CryptoJS.MD5;
CryptoJS.MD5 = function (...args) {
console.log("[MD5 input]:", args[0] && args[0].toString ? args[0].toString() : args[0]);
const result = original.apply(this, args);
console.log("[MD5 output]:", result.toString());
return result;
};
console.log("MD5 hooked");
})();
这样下次请求触发时,你就能在 Console 看到哈希输入和输出。
如果不是 CryptoJS,也可以 hook:
window.fetchXMLHttpRequest.prototype.sendJSON.stringify- 某个局部导出的工具函数
安全/性能最佳实践
这一节从“分析者”和“开发者”两个角度都说一下。
对分析者:保持最小化、可验证、可复现
-
先定位,再复现,不要盲目抄整站代码
- 只还原签名链路需要的最小逻辑
- 这样更稳定,也更容易排错
-
保存中间值
- 原始参数
- 排序后参数
- 拼接字符串
- 最终签名
-
对照实验
- 每次只改一个变量
- 看签名如何变化
-
注意授权边界
- 学习与调试可以
- 不要在未授权系统做批量调用或绕过风控
对前端/服务端开发者:不要把“前端签名”当作真正安全边界
这一点非常关键。前端签名的本质是:
- 增加滥用门槛
- 提高脚本模拟成本
- 辅助风控
- 提供一定的完整性校验
但它不能替代服务端安全。原因很简单:前端代码最终运行在用户环境里,理论上都能被分析。
更合理的做法
- 核心鉴权放服务端
- 短期 token + 服务端校验
- 配合限流、风控、设备指纹
- nonce + timestamp 防重放
- 服务端验证请求来源与行为模式
- 敏感 secret 不下发前端
性能方面的建议
如果你是开发者,在前端做签名时要注意:
- 不要在大对象上频繁深拷贝排序
- 避免每次请求都做重型加密
- 尽量复用序列化逻辑
- 明确 body 规范,减少前后端不一致
- 对大文件上传不要做同步阻塞型计算
逐步验证清单
如果你准备把本文的方法用于真实页面,这份清单可以直接照着做:
1. 在 Network 中锁定目标请求
2. 记录 sign/timestamp/nonce 等字段
3. 看 Initiator,判断来自 fetch/XHR/axios 哪一层
4. 在 Sources 添加 XHR/fetch 断点
5. 请求停住后,看 Call Stack
6. 找到参数被写入 sign 的那行代码
7. 继续进入签名函数
8. 记录:
- 入参
- 排序规则
- 拼接字符串
- secret
- hash/encrypt 算法
9. 在 Console 手动复算一次
10. 在 Node.js 本地写最小复现脚本
11. 与浏览器结果逐字符对比
12. 修改单一参数,确认算法稳定成立
一个最小完整示例:前后端验证思维
为了让整个流程更完整,这里再给一个“小闭环”示例:前端生成签名,服务端验证签名。
前端生成
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function makeClientSign(path, params) {
const secret = "#k9JmVqP3";
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join("&");
return md5(`${path}?${sorted}${secret}`);
}
const path = "/api/user/list";
const params = {
page: 1,
size: 20,
t: 1710000000000,
nonce: "ab12cd34"
};
const sign = makeClientSign(path, params);
console.log(sign);
服务端验证
const crypto = require("crypto");
function md5(text) {
return crypto.createHash("md5").update(text, "utf8").digest("hex");
}
function verifySign(path, params, clientSign) {
const secret = "#k9JmVqP3";
const payload = { ...params };
delete payload.sign;
const sorted = Object.keys(payload).sort().map(k => `${k}=${payload[k]}`).join("&");
const expected = md5(`${path}?${sorted}${secret}`);
return expected === clientSign;
}
const ok = verifySign(
"/api/user/list",
{
page: 1,
size: 20,
t: 1710000000000,
nonce: "ab12cd34"
},
"这里替换成客户端算出来的 sign"
);
console.log("verify =", ok);
这类最小示例的价值在于:你会更清楚自己到底在还原什么,而不是“把浏览器里的某段代码搬出来运行”。
总结
基于浏览器开发者工具做 Web 逆向,还原前端签名生成流程,最重要的不是背多少算法,而是掌握一条稳定方法:
- 先在 Network 找到异常签名字段
- 用 XHR/fetch 断点拦住请求发起
- 沿调用栈回溯,锁定签名函数
- 记录参与项、排序规则、拼接格式、算法类型
- 在 Console 和本地脚本中双重验证
- 通过单变量实验确认还原结果正确
如果你只记住一句话,那就是:
别直接猜签名,先抓“签名前的原始字符串”。
因为只要原始字符串和算法都对了,最终结果自然会对;反过来,只盯着最终 sign 去蒙,往往越猜越乱。
最后给你几个可执行建议:
- 遇到复杂站点,优先从请求断点切入,不要先看全量混淆代码
- 每次都保存中间值,尤其是拼接前后的字符串
- 先做最小复现,再考虑自动化
- 如果目标站点有明显反调试,优先 hook 关键 API 抓运行时数据
- 永远在合法授权的边界内分析与测试
只要你把“定位入口—观察变量—还原规则—本地验证”这条链路走熟,绝大多数前端签名场景都能拆开来看。