从抓包到补环境:中级开发者实战 Web 逆向中的前端加密参数还原
做 Web 逆向时,很多人卡的不是“包抓不到”,而是“包抓到了也发不出去”。接口参数看起来齐全,Cookie 也带了,请求头也像了,但服务端就是回你一句:sign invalid、illegal request 或者干脆空数据。
这篇文章我想从一个中级开发者真正会遇到的场景出发,带你走一遍完整流程:抓包定位 -> 找加密逻辑 -> 分析依赖 -> 补环境执行 -> 还原请求参数。重点不是某个站点的细节,而是这类问题背后的通用方法。
说明:本文内容用于安全研究、接口联调、自有系统测试与前端逻辑分析,请勿用于未授权的数据抓取或绕过访问控制。
背景与问题
典型场景是这样的:
- 页面里通过 XHR / Fetch 请求数据
- 参数里有
sign、token、t、nonce、encryptData等字段 - 这些字段不是固定值,而是每次请求动态生成
- 单纯复制浏览器中的请求参数,只能复现一次,甚至一次都不行
- 直接用 Python/Node 重放请求,服务端校验失败
这背后通常有三类前端保护逻辑:
- 纯拼接签名:例如
md5(path + data + timestamp + secret) - 轻度加密:AES/RSA/SM4 等对请求体或关键字段加密
- 环境绑定:签名逻辑依赖
window、document、navigator、location、Canvas、WebGL、LocalStorage 等浏览器对象
真正难的往往不是算法本身,而是第三类:代码能找到,但在 Node.js 里跑不起来。这时就进入“补环境”阶段了。
前置知识
如果你已经熟悉以下内容,阅读会顺很多:
- Chrome DevTools 的 Network、Sources、Debugger
- 基本 JavaScript 语法与闭包、原型链
- Node.js 运行方式
- 常见摘要/加密算法:MD5、SHA1、SHA256、AES、RSA
- 抓包工具基础:浏览器开发者工具、Charles、Fiddler、mitmproxy
环境准备
本文示例使用:
- Chrome
- Node.js 18+
- 一个文本编辑器或 VS Code
建议准备以下辅助能力:
- 浏览器格式化混淆代码的习惯
- 会打
XHR/fetch断点 - 会用
console.log临时插桩 - 知道如何将浏览器里的 JS 片段抽离到 Node 运行
整体流程先看一眼
先别急着抠代码,我建议先建立一个整体框架。很多时候逆向失败,不是技术不会,而是顺序乱了。
flowchart TD
A[抓包定位目标接口] --> B[分析请求参数结构]
B --> C[搜索 sign/token 生成点]
C --> D[断点跟栈追踪]
D --> E[抽离核心函数]
E --> F{是否依赖浏览器环境}
F -- 否 --> G[Node 直接运行验证]
F -- 是 --> H[补 window/document/navigator 等]
H --> I[还原参数生成]
G --> J[重放请求验证]
I --> J
J --> K[封装脚本与排查异常]
核心原理
前端加密参数还原,本质上是在回答三个问题:
1. 参数是怎么生成的?
比如一个请求:
{
"page": 1,
"size": 20,
"t": 1710000000000,
"nonce": "a8sd9f",
"sign": "4c7d..."
}
你需要确认:
sign用了哪些字段参与计算- 字段顺序是否固定
- 是否做了 JSON 序列化
- 是否进行了 URL 编码
- 是否拼接了固定盐值
- 时间戳单位是秒还是毫秒
很多人只看到 md5 就开始模仿,但真正校验失败的原因,常常是序列化细节不一致。
2. 参数生成依赖哪些运行环境?
比如函数内部可能用了:
navigator.userAgentwindow.location.hrefdocument.cookielocalStorage.getItem("token")Date.now()Math.random()
如果你把浏览器代码直接复制到 Node 中,常见报错就是:
window is not defineddocument is not definednavigator is not definedCannot read properties of undefined
这说明不是算法难,而是运行时上下文缺了。
3. 服务端校验关注的是“值”还是“过程”?
有的接口只认结果,只要你算出的 sign 对就行;
有的接口还会校验:
- 时间窗口
- 请求顺序
- Cookie/Session 绑定
- Token 是否从上一步接口获得
- 设备指纹与签名是否一致
也就是说,签名还原成功不代表请求一定成功。这点在排查时非常关键。
一个典型请求的逆向思路
我们以一个中等复杂度的场景举例:
- 请求方法:POST
- 请求体:JSON
- 参数中有
timestamp、nonce、sign sign由请求体、时间戳、UA 和本地 token 共同计算- 代码混淆后运行在浏览器里
它的实际调用关系,通常像这样:
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名模块
participant B as 浏览器环境
participant A as 接口服务端
U->>P: 点击查询
P->>B: 读取 cookie/localStorage/UA/时间
P->>S: 传入 body + env
S-->>P: 返回 sign/timestamp/nonce
P->>A: 携带加密参数发请求
A-->>P: 校验成功返回数据
背景与问题:从抓包开始看
假设我们在浏览器开发者工具里看到一个请求:
POST /api/data/list HTTP/1.1
Content-Type: application/json
X-Token: 9f3a...
请求体:
{
"page": 1,
"size": 20,
"keyword": "phone",
"timestamp": 1710000000123,
"nonce": "m8K2pQ",
"sign": "9f6e3c0d..."
}
而你把这个 body 原样复制到 Python 或 Postman 里重新发,请求失败。说明这里至少有一个事实成立:
sign与当前请求上下文强绑定;- 或者
timestamp已过期; - 或者
nonce只能用一次; - 或者
sign依赖别的头信息、Cookie、token。
这时第一步不是写代码,而是重新在浏览器里做“变量追踪”。
第一步:抓包分析与定位入口
1. 先看 Network,不急着看 Sources
重点观察:
- 请求 URL
- 请求方法
- Query 参数
- Body 类型
- Headers 中自定义字段
- Cookie/Authorization 是否参与
- 请求发起堆栈(Initiator)
如果浏览器支持,直接点请求的 Initiator,通常能跳到发请求的代码位置。这一步可以帮你快速定位调用链。
2. 搜索关键字段名
优先搜索:
signnoncetimestamp- 接口路径片段,如
/api/data/list - 自定义头名,如
X-Token
如果代码没被严重混淆,一般能找到签名入口函数。
3. 打断点看“调用前一刻”
我自己常用两种方式:
- 在
fetch/XMLHttpRequest.send处打断点 - 在构造请求参数的位置打断点
你要看的不是一坨混淆函数,而是:
- 最终送给接口的参数长什么样
- 在进入发送前,这些参数刚刚被谁赋值
- 是哪一个函数返回了
sign
第二步:识别签名是“纯算法”还是“带环境”
判断方式很简单。
如果某段代码长这样:
function makeSign(data, ts, nonce) {
const raw = JSON.stringify(data) + "|" + ts + "|" + nonce + "|SECRET";
return md5(raw);
}
那大概率是纯算法,直接搬到 Node 就行。
但如果像这样:
function makeSign(data) {
const token = localStorage.getItem("token") || "";
const ua = navigator.userAgent;
const href = location.href;
const ts = Date.now();
const nonce = randomString(6);
const raw = JSON.stringify(data) + token + ua + href + ts + nonce;
return {
ts,
nonce,
sign: sha256(raw)
};
}
这就是典型的带环境依赖,需要补环境。
第三步:抽离最小可运行代码
很多人一上来就把整份混淆 JS 扔进 Node,这样通常会被无关逻辑拖死。更稳的做法是:
- 找到签名入口函数
- 沿着调用链只提取必要函数
- 补最少的全局对象
- 先让它跑起来,再逐步补全
这个思路很像“做最小复现”。
实战代码(可运行)
下面我用一个可运行的模拟案例演示完整过程。它不是某个真实站点代码,但足够贴近实战。
浏览器侧原始逻辑(目标逻辑)
假设页面里真正执行的是下面这段代码:
function randomString(len) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let s = "";
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function stableStringify(obj) {
const keys = Object.keys(obj).sort();
const ret = {};
for (const k of keys) {
ret[k] = obj[k];
}
return JSON.stringify(ret);
}
async function sha256(text) {
const data = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
}
async function buildParams(payload) {
const ts = Date.now();
const nonce = randomString(6);
const token = localStorage.getItem("token") || "";
const ua = navigator.userAgent;
const href = location.href;
const body = stableStringify(payload);
const raw = [body, ts, nonce, token, ua, href].join("|");
const sign = await sha256(raw);
return {
...payload,
timestamp: ts,
nonce,
sign
};
}
这段逻辑依赖:
localStoragenavigatorlocationcrypto.subtleTextEncoder
在 Node 中补环境并运行
下面给出 Node 18+ 可运行版本:
const crypto = require("crypto");
global.navigator = {
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0.0.0 Safari/537.36"
};
global.location = {
href: "https://example.com/search"
};
global.localStorage = {
_data: {
token: "demo_token_123456"
},
getItem(key) {
return this._data[key] || null;
},
setItem(key, value) {
this._data[key] = String(value);
}
};
function randomString(len) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let s = "";
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function stableStringify(obj) {
const keys = Object.keys(obj).sort();
const ret = {};
for (const k of keys) {
ret[k] = obj[k];
}
return JSON.stringify(ret);
}
async function sha256(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
async function buildParams(payload) {
const ts = Date.now();
const nonce = randomString(6);
const token = localStorage.getItem("token") || "";
const ua = navigator.userAgent;
const href = location.href;
const body = stableStringify(payload);
const raw = [body, ts, nonce, token, ua, href].join("|");
const sign = await sha256(raw);
return {
...payload,
timestamp: ts,
nonce,
sign
};
}
(async () => {
const payload = {
page: 1,
size: 20,
keyword: "phone"
};
const params = await buildParams(payload);
console.log(params);
})();
运行:
node demo.js
输出示例:
{
page: 1,
size: 20,
keyword: 'phone',
timestamp: 1710000000123,
nonce: 'aZ8kP1',
sign: 'a4b7c1d8...'
}
第四步:带请求重放的完整示例
只算出参数还不够,最好立刻验证“能不能发成功”。
下面给一个可运行的请求重放模板:
const crypto = require("crypto");
global.navigator = {
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0.0.0 Safari/537.36"
};
global.location = {
href: "https://example.com/search"
};
global.localStorage = {
_data: {
token: "demo_token_123456"
},
getItem(key) {
return this._data[key] || null;
}
};
function randomString(len) {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let s = "";
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function stableStringify(obj) {
const keys = Object.keys(obj).sort();
const ret = {};
for (const k of keys) {
ret[k] = obj[k];
}
return JSON.stringify(ret);
}
function sha256(text) {
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}
function buildParams(payload) {
const ts = Date.now();
const nonce = randomString(6);
const token = localStorage.getItem("token") || "";
const ua = navigator.userAgent;
const href = location.href;
const body = stableStringify(payload);
const raw = [body, ts, nonce, token, ua, href].join("|");
const sign = sha256(raw);
return {
...payload,
timestamp: ts,
nonce,
sign
};
}
async function main() {
const payload = {
page: 1,
size: 20,
keyword: "phone"
};
const body = buildParams(payload);
const res = await fetch("https://httpbin.org/post", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Token": localStorage.getItem("token"),
"User-Agent": navigator.userAgent
},
body: JSON.stringify(body)
});
const json = await res.json();
console.log(JSON.stringify(json, null, 2));
}
main().catch(console.error);
这个示例主要用于验证两件事:
- 你的参数生成逻辑是否能稳定执行
- 你的请求重放流程是否完整
如果你在真实目标里替换 URL 和头部,这就成了最基础的自动化脚本骨架。
第五步:逐步验证清单
实战里我很少一步到位,而是按这个顺序验证:
验证 1:参数结构是否一致
核对:
- 字段名完全一致
- 大小写一致
- 是否缺字段
- 是否多字段
- 空字符串与
null是否被区别处理
验证 2:时间戳是否一致
核对:
- 秒级还是毫秒级
- 是否取整
- 是否服务端要求固定时间窗口
- 是否与请求头中的时间字段联动
验证 3:序列化结果是否一致
这是重灾区。
比如下面这几种看似差不多,实际哈希完全不同:
JSON.stringify({a:1,b:2})
JSON.stringify({b:2,a:1})
'{"a":1,"b":2}'
'{"a":"1","b":"2"}'
如果原站点做了 key 排序、过滤空值、数字转字符串,你都必须跟上。
验证 4:环境值是否一致
核对:
navigator.userAgentlocation.hrefdocument.referrerlocalStorage中 token- Cookie
- 屏幕尺寸、时区、语言
验证 5:算法结果是否逐步对齐
如果浏览器里可断点,我建议直接在浏览器控制台打印中间值:
- 原始拼接串
raw - 时间戳
ts - 随机串
nonce - token
- 最终 sign
然后与你在 Node 中的每一步输出做 diff。
不要只对比最终 sign,那样排错成本太高。
补环境到底补什么?
补环境不是无脑造一个 window = {} 就完了。要根据报错和调用链来补。
下面是一个常见依赖图:
classDiagram
class window {
navigator
location
document
localStorage
sessionStorage
}
class navigator {
userAgent
language
platform
}
class location {
href
host
pathname
}
class document {
cookie
referrer
}
class localStorage {
getItem()
setItem()
}
window --> navigator
window --> location
window --> document
window --> localStorage
最小补环境示例
global.window = global;
global.navigator = {
userAgent: "Mozilla/5.0",
language: "zh-CN",
platform: "Win32"
};
global.location = {
href: "https://example.com/path?a=1",
host: "example.com",
pathname: "/path"
};
global.document = {
cookie: "sid=abc123; token=xyz789",
referrer: "https://example.com/home"
};
global.localStorage = {
_data: {},
getItem(k) {
return this._data[k] || null;
},
setItem(k, v) {
this._data[k] = String(v);
}
};
global.sessionStorage = {
_data: {},
getItem(k) {
return this._data[k] || null;
},
setItem(k, v) {
this._data[k] = String(v);
}
};
注意一点:
补环境的目标不是“像浏览器一样完整”,而是“满足目标函数执行所需”。够用就行。
常见坑与排查
这一段我尽量写得接地气一点,因为这些坑我自己都踩过。
坑 1:只复刻了算法,没复刻输入
最常见。你看见 md5、sha256 就觉得结束了,但服务端验的是:
- 排序后的 JSON
- URL 编码后的字符串
- 带 token 的拼接串
- 带固定前缀/后缀的内容
排查方式:打印浏览器端参与签名的原文字符串,与 Node 输出逐字符对比。
坑 2:对象顺序不一致
JavaScript 对象遍历顺序、构造方式、序列化前处理方式,都可能影响最终签名。
例如目标代码里用了:
Object.keys(data).sort()
而你直接:
JSON.stringify(data)
结果就不一样。
排查方式:自己实现 stableStringify,或者在浏览器里把签名前的字符串打印出来。
坑 3:随机数与时间戳没对齐
有些站点要求:
nonce长度固定- 只能字母数字
- 时间戳必须在服务端允许的偏差范围内
nonce与timestamp共同参与验签
排查方式:
- 检查
Date.now()是否应转秒 - 检查随机串字符集
- 尝试在签名后立即发请求,避免过期
坑 4:Node 和浏览器的 API 不一致
比如浏览器里是:
crypto.subtle.digest(...)
Node 里你却直接照搬,结果报错。
排查方式:
- 浏览器 Web Crypto 在 Node 中不一定兼容调用方式
- 简单哈希类操作优先替换为
require("crypto") - 若目标站点强依赖
window.crypto,再考虑更完整的 polyfill
坑 5:补环境补少了,或者补“假了”
有的代码会连环读取:
window.navigator.userAgent
window.location.href
document.cookie
你只补了 navigator,没补 window.navigator,仍然会炸。
排查方式:
- 报错看栈,不要猜
- 从缺哪个属性补哪个属性
- 适当在 getter 上打日志,观察访问路径
例如:
global.navigator = new Proxy({
userAgent: "Mozilla/5.0"
}, {
get(target, prop) {
console.log("navigator get:", prop);
return target[prop];
}
});
这招在定位环境依赖时很实用。
坑 6:代码有反调试/自校验
一些前端代码会检测:
Function.prototype.toString- debugger
- DevTools 开启状态
- 代码是否被篡改
- 是否在浏览器环境中
这类情况不是简单补环境就能过。
排查方式:
- 先找到真正业务签名入口
- 避开外围反调试壳
- 优先抽离已执行后的核心函数,而不是硬啃整包
说白了,别一上来就跟整套混淆对抗,先找“有效载荷”。
安全/性能最佳实践
虽然我们讨论的是逆向与参数还原,但落到工程实现,也要注意边界。
1. 不要把补环境脚本写成“全局污染怪兽”
建议把环境封装成工厂函数,避免不同接口逻辑互相影响。
function createEnv() {
return {
navigator: {
userAgent: "Mozilla/5.0"
},
location: {
href: "https://example.com/"
},
localStorage: {
_data: { token: "demo" },
getItem(k) {
return this._data[k] || null;
}
}
};
}
然后在签名函数里显式传入依赖,比全局乱挂更稳。
2. 优先做“最小可运行抽离”
整包执行有几个问题:
- 慢
- 依赖多
- 容易被反调试干扰
- 不利于维护
最好做法是:
- 抽出核心签名函数
- 只保留必要辅助函数
- 补最少环境
- 建立回归样例
3. 给签名逻辑做样例固化
一旦某个站点的签名跑通,建议立刻保存一组固定输入与输出。
例如:
const sampleInput = {
page: 1,
size: 20,
keyword: "phone"
};
const fixedEnv = {
token: "demo_token_123456",
ua: "Mozilla/5.0",
href: "https://example.com/search",
ts: 1710000000123,
nonce: "abc123"
};
这样以后站点更新了,你能快速判断:
- 算法变了
- 序列化变了
- 还是环境变了
4. 控制请求频率,尊重授权边界
这不是套话,是真的重要。即使你能还原参数,也不代表你可以无约束地调用接口。
建议:
- 仅在授权范围内分析
- 控制请求频率
- 不绕过鉴权
- 不抓取敏感数据
- 对自己的脚本设置速率限制与日志
5. 性能上优先缓存稳定环境值
像这些值通常没必要每次重新计算:
userAgentlocation- 固定 token(若短时间不变)
- 解析后的常量盐值
而这些值应每次实时生成:
timestampnonce- 部分一次性 token
这样脚本会更稳,调试也更容易。
一个实战中的推荐排查路径
如果你现在手头就有一个“请求发不出去”的目标,我建议按这个顺序做:
stateDiagram-v2
[*] --> 抓包确认接口
抓包确认接口 --> 找请求构造代码
找请求构造代码 --> 找sign生成入口
找sign生成入口 --> 打印中间变量
打印中间变量 --> 抽离最小函数
抽离最小函数 --> 补最小环境
补最小环境 --> 本地生成sign
本地生成sign --> 重放请求
重放请求 --> 成功
重放请求 --> 失败
失败 --> 对比浏览器与本地输入
对比浏览器与本地输入 --> 补充环境或修正序列化
补充环境或修正序列化 --> 本地生成sign
成功 --> [*]
这个路径的关键点是:
每一步都要有可验证产物。
比如“中间原文字符串一致”“时间戳一致”“nonce 长度一致”“sign 一致”。
不要模糊地觉得“应该差不多了”。Web 逆向里,差一个字符都不行。
总结
从抓包到补环境,还原前端加密参数,核心不是“会不会某种算法”,而是建立一套稳定的方法论:
- 先抓包,确认请求结构
- 定位参数生成入口,而不是盲猜
- 判断是纯算法还是环境依赖
- 抽离最小可运行代码
- 按需补环境,不求完整浏览器
- 逐步对齐中间值,而不是只盯最终 sign
- 重放验证,闭环确认
如果你是中级开发者,我特别建议你把注意力从“搜现成脚本”转到“构建自己的排查框架”上。因为站点会变、混淆会变、参数名会变,但这套思路基本不变。
最后给几个很实用的可执行建议:
- 第一时间打印“签名前原文”
- 遇到报错,按调用栈补环境,不要盲补
- 优先抽离核心函数,不要直接硬跑整包
- 为每个已跑通的站点保留固定样例做回归
- 如果接口校验失败,不只看 sign,还要看 Cookie、token、时效与上下文绑定
如果你把这套流程练熟,很多“看起来很玄学”的前端加密参数,其实都会变成一个个可拆解、可验证的工程问题。