Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法
前端“加密参数”这件事,很多人第一次碰到时都会有点懵:接口明明抓到了,参数也看见了,但一旦换成脚本复现,请求就失败。最典型的情况是 sign、token、cipher、t、_signature 这类字段,看起来像是前端临时算出来的。
这篇文章我不讲“玄学经验”,而是按一条可重复、能落地、能排错的路径,带你从浏览器开发者工具出发,把前端加密请求参数一步步定位并还原出来。你看完后,至少能建立一套判断框架:参数在哪里生成、依赖哪些输入、如何最小化复现。
说明:本文聚焦技术分析方法,用于学习前端调试、接口联调、安全研究与自动化测试。请仅在合法合规授权范围内使用。
背景与问题
我们先定义一下问题。
在实际站点中,前端常见的“加密请求参数”通常不是传统意义上的严格加密,而是下面几类之一:
- 摘要签名:如
md5/sha256/hmac - 对称加密:如
AES-CBC/AES-ECB - 非对称加密:如
RSA加密某个字段 - 混淆编码:如
base64、字符位移、数组重排 - 动态字段拼接:时间戳、随机数、设备指纹、页面状态共同生成
典型现象包括:
- 浏览器里接口正常,脚本复现返回“签名错误”
- 参数每次都变,无法直接复用
- 请求头里有特殊字段,如
x-sign、x-timestamp - 参数是长字符串,表面看不出规律
- 同一个接口,刷新页面后关键算法文件名变化
这些场景,本质上都绕不开三个问题:
- 参数是在哪生成的?
- 生成时依赖了哪些输入?
- 如何脱离浏览器环境稳定复现?
前置知识与环境准备
如果你已经熟悉 DevTools 和一点 JavaScript,可以直接跳到实战部分。否则建议先准备好这些工具:
- Chrome 或 Edge 浏览器
- 开发者工具(Network / Sources / Console)
- 一个本地 Node.js 环境
- 可选:格式化 JS 的插件或在线 beautify 工具
建议至少知道以下概念:
XMLHttpRequest和fetch- 请求头、请求体、查询参数
- 断点、调用栈、作用域链
- 常见加密库特征:
CryptoJS、JSEncrypt、md5
核心原理
1. 前端加密参数的真实生成链路
很多人上来就全局搜索 sign,结果搜不到,于是觉得“被混淆了,没法搞”。其实更稳的办法不是从变量名猜,而是从请求发起链路反推。
一个加密参数从“页面动作”到“真正发出请求”,通常会经过这条链路:
flowchart LR
A[用户操作/页面初始化] --> B[业务函数组装参数]
B --> C[签名或加密函数]
C --> D[请求封装器 axios/fetch/xhr]
D --> E[浏览器发包]
E --> F[服务端验签/解密]
我们的目标,就是在 C 或 D 这个位置拦住它。
2. DevTools 的核心思路:先抓包,再逆调用栈
最有效的方法一般是:
- 在 Network 找到目标请求
- 看清楚:
- URL
- Method
- Query String
- Request Payload / Form Data
- Request Headers
- 锁定关键字段,例如
sign - 回到 Sources,对请求发起点或加密 API 打断点
- 利用 Call Stack 倒推出参数生成函数
这一步非常关键。
因为变量名可以混淆,调用链很难伪装。
3. 加密参数常见输入源
你定位到签名函数后,不要急着抄代码,先看它依赖什么。大多会来自:
- 请求路径,如
/api/search - 请求方法,如
POST - 请求体 JSON 字符串
- 时间戳
Date.now() - 随机数
Math.random() - Cookie / LocalStorage / SessionStorage
- 页面内置常量或“盐值”
- 设备信息,如 UA、屏幕信息、指纹值
可以把它理解成一个公式:
sign = F(url, method, body, timestamp, nonce, secret, env)
只要你漏掉一个输入,复现就可能失败。
一套实战方法:从请求到算法还原
下面我按我自己常用的一条路径来演示,这种路径的好处是:即使站点混淆较重,也能逐层缩小范围。
第一步:在 Network 中锁定目标请求
打开 DevTools,进入 Network 面板,勾选:
- Preserve log
- Disable cache
然后执行一次目标操作,比如点击“搜索”或“加载更多”。
重点观察:
- 是否有接口返回业务数据
- 参数是否包含明显签名字段
- 请求头中是否有定制字段
例如我们看到这样的请求体:
{
"keyword": "phone",
"page": 1,
"ts": 1720000000000,
"sign": "8f8e5f0d2a0f..."
}
这时可以初步推断:
ts是参与签名的时间戳sign很可能是keyword + page + ts + secret一类组合计算
第二步:优先看 Initiator
在 Network 选中请求后,看 Initiator 或调用来源。
这里常常能直接跳到发起请求的 JS 文件。
如果是打包后的文件名如:
app.3a91f.jschunk-vendors.82a0.js
也别慌,先点进去。
第三步:对请求发起点下断点
对以下位置优先下断点:
fetch(...)XMLHttpRequest.prototype.openXMLHttpRequest.prototype.send- axios 请求拦截器
- 构造请求对象的那一行
如果站点使用 fetch,我常用的一个简单技巧是在 Console 里先做 hook:
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log('fetch args:', args);
debugger;
return rawFetch.apply(this, args);
};
如果是 XHR:
(function () {
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return rawOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
console.log('XHR =>', this._method, this._url, body);
debugger;
return rawSend.call(this, body);
};
})();
这样做的意义不是直接拿算法,而是先让请求停下来,你才能顺着调用栈往上追。
第四步:从调用栈追到签名函数
当断点触发后,打开 Call Stack。
一般你会看到类似这样一串调用:
sendRequest
buildPayload
genSign
md5
此时重点观察:
- 哪一层开始出现
sign sign的原始输入是什么- 是否调用了第三方库,如
CryptoJS.MD5(...)
如果你看到这类代码,基本就接近答案了:
function genSign(data, ts) {
const raw = JSON.stringify(data) + "|" + ts + "|" + "SECRET_123";
return md5(raw);
}
这时候不要只记下 md5,而要记完整公式:
data是否需要固定字段顺序JSON.stringify前是否做了排序- 分隔符是不是
| - secret 是硬编码还是动态来的
第五步:验证参数依赖关系
我建议在浏览器里现场验证,而不是直接去 Python 或 Node 复现。
比如在 Console 执行:
JSON.stringify({ keyword: "phone", page: 1 }) + "|" + 1720000000000 + "|" + "SECRET_123"
再和断点中实际输入做比对。
这里我踩过一个很常见的坑:
看起来对象内容一样,但字段顺序不一样,最终 sign 就不一样。
所以要特别注意:
- 对象键顺序
- 数字是否被转成字符串
- 请求体是否压缩空格
undefined字段是否被过滤- URL 参数是否做了
encodeURIComponent
Mermaid:完整定位流程图
flowchart TD
A[Network 找到目标请求] --> B[识别关键参数 sign/ts/token]
B --> C[查看 Initiator 和 Sources]
C --> D[对 fetch/xhr/axios 下断点]
D --> E[断点触发后查看 Call Stack]
E --> F[定位参数构造函数]
F --> G[确认输入项 body/url/ts/nonce/secret]
G --> H[浏览器内 Console 复算验证]
H --> I[Node/Python 脱离浏览器复现]
实战代码(可运行)
下面我用一个简化但真实感很强的例子演示。目标是还原一个前端签名请求。
场景设定
前端请求发送的数据如下:
{
"keyword": "phone",
"page": 1,
"ts": 1720000000000,
"sign": "..."
}
经过断点分析后,发现前端签名逻辑是:
- 取业务字段
keyword、page - 按键名升序排序
- 拼成查询串:
keyword=phone&page=1 - 再拼接时间戳与 secret
- 最后做
md5
公式如下:
sign = md5("keyword=phone&page=1|1720000000000|SECRET_123")
浏览器端验证代码
可以先在 Console 里执行这段代码验证思路:
function buildQuery(obj) {
return Object.keys(obj)
.sort()
.map(k => `${k}=${obj[k]}`)
.join("&");
}
// 一个简化的 md5 占位说明:
// 在真实站点里你可能会直接调用页面已有的 md5 函数。
// 如果页面里已经加载了 CryptoJS,可直接用 CryptoJS.MD5。
function demoRawString(data, ts, secret) {
const query = buildQuery(data);
return `${query}|${ts}|${secret}`;
}
const data = { keyword: "phone", page: 1 };
const ts = 1720000000000;
const secret = "SECRET_123";
console.log(demoRawString(data, ts, secret));
// 输出:keyword=phone&page=1|1720000000000|SECRET_123
如果页面里已有 CryptoJS,继续这样验证:
function buildQuery(obj) {
return Object.keys(obj)
.sort()
.map(k => `${k}=${obj[k]}`)
.join("&");
}
function genSign(data, ts, secret) {
const raw = `${buildQuery(data)}|${ts}|${secret}`;
return CryptoJS.MD5(raw).toString();
}
const data = { keyword: "phone", page: 1 };
const ts = 1720000000000;
const secret = "SECRET_123";
console.log(genSign(data, ts, secret));
Node.js 复现代码
下面给出一个可直接运行的 Node.js 版本。
先保存为 sign_demo.js:
const crypto = require("crypto");
function buildQuery(obj) {
return Object.keys(obj)
.sort()
.map((k) => `${k}=${obj[k]}`)
.join("&");
}
function genSign(data, ts, secret) {
const raw = `${buildQuery(data)}|${ts}|${secret}`;
return crypto.createHash("md5").update(raw).digest("hex");
}
function buildPayload(keyword, page) {
const data = { keyword, page };
const ts = Date.now();
const secret = "SECRET_123";
const sign = genSign(data, ts, secret);
return {
...data,
ts,
sign,
};
}
const payload = buildPayload("phone", 1);
console.log(payload);
运行:
node sign_demo.js
你会得到类似结果:
{
keyword: 'phone',
page: 1,
ts: 1720000000000,
sign: '8d1b4d2f9f6e2f7d...'
}
Python 复现代码
如果你更习惯 Python,也可以这样写:
import time
import hashlib
def build_query(data):
return "&".join(f"{k}={data[k]}" for k in sorted(data.keys()))
def gen_sign(data, ts, secret):
raw = f"{build_query(data)}|{ts}|{secret}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def build_payload(keyword, page):
data = {
"keyword": keyword,
"page": page
}
ts = int(time.time() * 1000)
secret = "SECRET_123"
sign = gen_sign(data, ts, secret)
result = dict(data)
result["ts"] = ts
result["sign"] = sign
return result
if __name__ == "__main__":
payload = build_payload("phone", 1)
print(payload)
请求还原的逐步验证清单
实际做项目时,我建议你不要“一把梭”直接写完整脚本,而是按下面顺序逐项验证:
1. 先验证原始拼接串
确认 raw string 是否和浏览器里的一致:
keyword=phone&page=1|1720000000000|SECRET_123
2. 再验证 hash / 加密结果
确认 md5(raw) 与浏览器一致。
3. 再验证请求体结构
确认最终发送的是:
- JSON 体
- 表单体
- URL 查询参数
4. 再验证请求头
尤其是:
content-typerefereroriginx-signauthorization
5. 最后验证时效性
如果签名 5 秒、30 秒或 60 秒失效,重放旧参数一定会失败。
Mermaid:请求参数生成时序图
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名函数
participant R as 请求模块
participant B as 浏览器
participant API as 服务端
U->>P: 点击搜索
P->>S: 传入业务参数 data
S->>S: 拼接 data + ts + secret
S->>P: 返回 sign
P->>R: 组装 payload
R->>B: 发起 HTTP 请求
B->>API: 发送 payload
API->>API: 验签
API-->>B: 返回结果
常见坑与排查
这一节非常重要。很多时候不是你没找到算法,而是复现时细节差了一点点。
1. 参数排序问题
常见现象:
- 浏览器里正常
- 自己复现总是“签名错误”
排查重点:
- 是否按键名排序
- 是否排除了空字段
- 是否包含
sign自身参与签名
例如下面两种 raw 就不同:
keyword=phone&page=1
page=1&keyword=phone
如果服务端按第一种验签,你发第二种肯定不行。
2. JSON 序列化不一致
比如前端实际签的是:
JSON.stringify({ keyword: "phone", page: 1 })
而你自己手写成:
{"page":1,"keyword":"phone"}
虽然语义一样,但字符串已经变了。
建议做法:
- 尽量复用原始序列化逻辑
- 不要“看起来一样就行”
3. 时间戳精度不同
有些站点要:
- 秒级:
Math.floor(Date.now() / 1000) - 毫秒级:
Date.now() - 微秒字符串:后端自定义格式
如果你把毫秒传成秒,签名当然对不上。
4. 随机数或 nonce 漏掉
有些请求参数除了 ts 还要 nonce:
nonce = Math.random().toString(36).slice(2)
如果你只还原了 sign,但没同步生成 nonce,服务端也可能拒绝。
5. 环境依赖没补齐
这是我最常见到的一个坑。
前端代码可能依赖:
windowdocumentnavigator.userAgentlocation.hrefcanvas指纹localStorage
你把函数直接复制到 Node.js 里跑,会报:
window is not defined
这时不要一股脑上 jsdom,先判断到底依赖了哪些环境值。很多时候只需要最小 mock:
global.window = {};
global.navigator = {
userAgent: "Mozilla/5.0 ..."
};
6. 混淆后找不到函数名
常见表现:
- 全是
a(b,c)、_0x12af(...) - 搜
sign、md5没结果
这时建议从这几个点切入:
- 搜
CryptoJS - 搜
digest - 搜
encrypt - 搜
setRequestHeader - 搜
fetch(、.send( - 在 XHR/fetch 上打断点,靠调用栈回溯
记住一句话:不要和混淆后的变量名硬刚,直接追执行链。
7. 不是“算法错”,而是 Header 错
有些接口验签之外,还校验:
originrefererx-requested-withsec-fetch-site
特别是一些风控较强的站点,签名正确也不代表一定通过。
如何判断是摘要、加密还是混淆
很多读者卡在第一步:看见一段长字符串,不知道该从哪里判断。
这里给一个很实用的经验表。
| 表现 | 可能类型 | 特征 |
|---|---|---|
| 32 位十六进制 | MD5 | 例如 5d41402abc4b2a76b9719d911017c592 |
| 40 位十六进制 | SHA1 | 较常见于旧实现 |
| 64 位十六进制 | SHA256 | 常用于 stronger sign |
| 很长的 Base64 字符串 | AES / RSA / 编码 | 常带 = 结尾 |
| 每次不同且超长 | RSA / AES + 随机 IV | 需继续看输入 |
| 可逆解码后仍像结构化文本 | Base64 / 混淆 | 优先尝试解码 |
我的经验是:
- 短定长十六进制,优先猜摘要
- 长 Base64,优先猜加密
- 参数不长但复杂,优先怀疑是拼接规则 + hash
安全/性能最佳实践
这一节既是给做分析的人,也是给前端/服务端开发者的建议。
1. 不要把“前端加密”当真正安全边界
前端代码最终会下发到用户浏览器。
只要代码能执行,就理论上能被调试、观察、还原。
所以:
- 前端签名只能提升滥用门槛
- 不能代替服务端认证、授权、频控
- 不要把核心密钥完全暴露在前端
2. 服务端必须做二次校验
建议至少校验:
- 时间戳窗口
- nonce 去重
- 用户身份绑定
- 签名字段完整性
- 请求频率限制
否则前端再复杂,仍然只是“拦君子不拦会调试的人”。
3. 前端实现尽量可维护
如果你是开发方,不建议为了“防逆向”把代码混淆到自己都维护不了。更合理的做法是:
- 核心逻辑放服务端
- 前端只做轻量签名
- 版本升级时保留日志与埋点
- 对异常请求做风险评分
4. 自动化复现时尽量做最小实现
从分析者角度,性能最佳实践是:
- 不要整个浏览器环境照搬
- 先提取纯算法函数
- 必要时再补环境 mock
- 尽量把算法与请求逻辑解耦
这样后续维护成本低,也更容易定位更新点。
一个更贴近实战的定位策略
如果目标站点比较复杂,我一般会按这个优先级来:
路线 A:从请求发起点追
适合大多数场景。
- Network 找请求
- Sources 找 initiator
- 断在 fetch/xhr
- 看调用栈
优点:稳。
缺点:调用链长时要耐心一点。
路线 B:从加密特征追
适合明显使用常见库的场景。
比如搜索:
CryptoJS.MD5CryptoJS.AES.encryptJSEncryptcreateHash
优点:快。
缺点:被封装或混淆后容易失效。
路线 C:从请求封装器追
适合 Vue/React 项目里统一封装 axios 的情况。
找:
- request interceptor
- response interceptor
- headers 注入逻辑
- 统一签名函数入口
优点:经常能一次抓到全部接口共用逻辑。
缺点:个别接口可能有额外处理。
实战中的边界条件
这里把话说透一点:不是所有站点都能轻松“纯算法还原”。
以下情况会明显提高难度:
- 签名依赖 WebAssembly
- 混合使用 Canvas/WebGL 指纹
- 动态下发临时公钥或 token
- 服务端参与 challenge-response
- 参数依赖登录态、cookie、设备行为轨迹
这时你要做的不是死磕“单函数还原”,而是先判断目标属于哪一层:
- 纯前端算法可复现
- 前端算法 + 少量环境依赖
- 浏览器行为强绑定,必须带真实环境
- 服务端动态参与,无法完全离线复现
判断清楚层级,比盲目抄代码重要得多。
总结
把前端加密请求参数还原出来,真正有用的不是“记住某个站点的算法”,而是掌握一套通用方法:
- 先在 Network 锁定目标请求
- 再从 Initiator / Sources 找发起链路
- 对 fetch / XHR / axios 下断点
- 借助调用栈定位签名函数
- 确认完整输入,不只盯着 sign 本身
- 先在浏览器内复算验证,再脱离浏览器复现
- 遇到失败优先排查排序、序列化、时间戳、环境依赖
如果你现在就要上手,我建议按这个最小行动清单开始:
- 找到一个带
sign的请求 - 记录请求体和请求头
- 在
fetch或xhr.send上打断点 - 观察
sign生成前的原始输入串 - 用 Console 手动复算一次
- 再迁移到 Node 或 Python
只要你把“抓包 -> 断点 -> 调用栈 -> 输入验证 -> 脱环境复现”这条链走顺了,大多数前端签名问题都不会再显得那么神秘。真正难的,不是算法本身,而是有没有按正确顺序把它拆开。