背景与问题
做 Web 逆向时,最让人头疼的往往不是“有没有接口”,而是“接口明明抓到了,参数却还原不出来”。
典型场景一般长这样:
- 页面请求里有
sign、token、ciphertext、enc_data之类的字段 - 参数每次都变,直接重放一定失败
- 前端代码被压缩、混淆,关键逻辑埋在一大坨 bundle 里
- 你明明知道“加密一定在前端做过”,但就是找不到入口
这类问题如果只靠“到处搜关键字”“盲断点”“碰运气 hook”,效率会很低。真正能提高成功率的,是建立一套从抓包定位到参数还原的系统方法论。
这篇文章我想从架构化分析的角度来讲,不只讲“怎么解一个站”,而是讲一套中级阶段可复用的路径:
- 先从网络层确定目标参数和调用链
- 再从运行时定位参数生成点
- 最后把前端逻辑抽离成可独立运行的代码
- 在此基础上做稳定性、性能和排障优化
注意,本文讨论的是授权测试、学习研究、CTF/靶场、企业自测等合法场景。不要用于未授权目标。
先建立整体视角:逆向的不是“密文”,而是“生成系统”
很多人一开始会盯着那个 sign 值本身,比如:
- 它是 MD5 吗?
- 是 AES 吗?
- 是 RSA 吗?
- 里面是不是时间戳拼接了盐?
这些猜测有时有帮助,但更有效的思路是:
不要先猜算法,先找“这个值在前端系统里是如何被生产出来的”。
也就是说,我们逆向的对象不是一个孤立参数,而是一条生产链:
flowchart LR
A[抓包定位请求] --> B[识别关键参数]
B --> C[关联调用栈]
C --> D[定位生成函数]
D --> E[分析输入来源]
E --> F[抽离依赖环境]
F --> G[本地复现生成逻辑]
G --> H[接口稳定重放]
真正困难的地方,往往不在“加密算法很复杂”,而在于它混合了这些因素:
- 请求体规范化:排序、过滤空值、字段拼接
- 时间戳、随机数、nonce
- 浏览器环境依赖:
window、navigator、document、localStorage - token、cookie、session 参与计算
- wasm、web worker、第三方加密库
- 混淆和反调试
所以中级阶段最重要的能力,不是会背几个算法,而是会把整个参数生产过程拆开。
核心原理
1. 从抓包视角看:先区分“哪类参数值得追”
拿到一个请求后,不是所有参数都值得逆。优先级建议如下:
第一类:决定成败的校验参数
例如:
signsignaturetokenx-sx-signauthverify
这些往往是服务器验签的核心字段,不还原就没法稳定调用。
第二类:看起来像密文的业务参数
例如:
datapayloadenc_dataparams
它们可能是:
- 明文 JSON 再 base64
- AES/RSA 加密串
- URL-safe 编码
- 压缩后再编码
第三类:环境协同参数
例如:
- 时间戳
- nonce
- traceId
- deviceId
- session 派生值
这类参数本身不一定复杂,但常常是验签输入的一部分。
我通常会先做一个表,把请求字段分成三类:
| 字段 | 表现形式 | 是否变化 | 猜测角色 |
|---|---|---|---|
| timestamp | 数字 | 每次变化 | 时间因子 |
| nonce | 随机字符串 | 每次变化 | 防重放 |
| sign | 十六进制/长串 | 每次变化 | 核心校验 |
| data | base64/密文 | 按业务变化 | 业务载荷 |
这样后续分析更有方向,不容易在杂项里迷路。
2. 从运行时视角看:参数一定经历了“明文到目标值”的转换
不管代码混淆得多厉害,参数如果来自前端,就几乎一定要经过以下某种链路:
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant E as 参数处理层
participant C as 加密/签名函数
participant X as XMLHttpRequest/fetch
participant S as 服务端
U->>P: 触发搜索/翻页/提交
P->>E: 组装业务参数
E->>C: 排序/拼接/加密/签名
C-->>E: 返回 sign/data
E->>X: 发送请求
X->>S: HTTP Request
这个链路的意义在于:
即使你看不懂整个项目,也可以在这条链路上找切入点。
常用切入点
- XHR/fetch 发送前
- 看最终 body、headers 是怎么来的
- 加密库入口
- 比如
CryptoJS.MD5、AES.encrypt、JSEncrypt.prototype.encrypt
- 比如
- 序列化入口
JSON.stringifyencodeURIComponentbtoa
- 时间和随机数
Date.nowMath.randomcrypto.getRandomValues
很多时候,我并不是先去读 bundle,而是先在这些 API 上做 hook,让运行时自己“吐出”关键线索。
3. 参数还原的本质:算法 + 输入 + 环境
一个参数能否成功复现,通常由三件事决定:
算法
例如:
- MD5/SHA 系列
- HMAC
- AES/RSA
- 自定义字符置换
- base64/urlencode/压缩
输入
例如:
- 请求参数原文
- token/cookie
- 时间戳
- 固定盐值
- 页面埋点值
- 设备指纹
环境
例如:
- 浏览器对象
- localStorage 中缓存
- JS 执行上下文
- WebAssembly 实例
- 某些初始化流程
很多“明明算法抠出来了却跑不通”的问题,本质上不是算法错了,而是输入漏了或者环境不对。
方案对比与取舍分析
在实际项目里,前端加密接口的处理方式大致有三条路:
方案 A:浏览器内直接复用页面环境
做法:
- 打开页面
- 在控制台或注入脚本中直接调用前端现成函数
- 借助 Playwright/Puppeteer 做自动化
优点
- 环境最真实
- 成功率高
- 对复杂站点、强环境依赖站点很友好
缺点
- 性能一般
- 批量调用成本高
- 页面升级后容易失效
- 自动化特征更明显
方案 B:抽离 JS 逻辑,在 Node.js 中本地运行
做法:
- 从页面中提取核心函数
- 补齐依赖
- 在 Node 中生成参数
优点
- 性能较好
- 易集成到脚本和服务
- 便于做批量请求
缺点
- 环境补齐成本高
- 对混淆/动态加载代码不够友好
- 维护要求更高
方案 C:重写算法,用 Python/Go/Node 重新实现
做法:
- 读懂原始逻辑
- 完全脱离前端,自己实现签名和加密流程
优点
- 最稳定
- 性能最好
- 最适合长期维护
缺点
- 前期成本最高
- 复杂站点难度大
- 漏掉边界条件就会验签失败
我个人的建议
中级阶段推荐采用两段式策略:
- 先用方案 A/B 快速打通
- 核心目标是拿到可用参数生成链
- 再视收益决定是否走方案 C
- 如果接口长期要用,再做重写和工程化
可以简单理解为:
- 验证期:优先“快”
- 稳定期:优先“准”
- 规模期:优先“稳 + 省资源”
实战代码(可运行)
下面给一个可运行的教学级示例。它不是针对某个真实站点,而是模拟一个典型前端签名流程:
- 业务参数按 key 排序
- 去掉空值
- 拼接时间戳和 token
- 生成
sign - 构造请求体
这样能把“参数还原”这件事讲透。
示例一:Node.js 还原签名逻辑
const crypto = require('crypto');
/**
* 规范化参数:
* 1. 去掉 null/undefined/""
* 2. key 按字典序排序
* 3. 统一拼接成 k=v&k2=v2
*/
function normalizeParams(params) {
return Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== '')
.sort()
.map((key) => `${key}=${String(params[key])}`)
.join('&');
}
/**
* 模拟前端 sign 生成逻辑
* sign = md5(normalized + "|" + timestamp + "|" + token + "|" + salt)
*/
function buildSign(params, timestamp, token) {
const salt = 'mid_level_reverse';
const normalized = normalizeParams(params);
const raw = `${normalized}|${timestamp}|${token}|${salt}`;
return crypto.createHash('md5').update(raw, 'utf8').digest('hex');
}
/**
* 构造最终请求体
*/
function buildPayload(params, token) {
const timestamp = Date.now();
const sign = buildSign(params, timestamp, token);
return {
data: params,
timestamp,
sign
};
}
// 测试运行
const token = 'test_token_123';
const params = {
keyword: '逆向',
page: 1,
pageSize: 20
};
const payload = buildPayload(params, token);
console.log('payload =>', payload);
这个例子虽然简单,但已经覆盖了实战里最常见的几个要素:
- 参数排序
- 空值过滤
- 时间戳参与计算
- token 参与计算
- 固定盐值参与计算
很多真实站点,本质也不过是这个框架再叠加几层编码或加密。
示例二:Python 复刻同样逻辑
当你需要把结果接入爬虫、测试脚本或后端服务时,Python 版很实用。
import hashlib
import time
def normalize_params(params: dict) -> str:
items = []
for k in sorted(params.keys()):
v = params[k]
if v is None or v == "":
continue
items.append(f"{k}={v}")
return "&".join(items)
def build_sign(params: dict, timestamp: int, token: str) -> str:
salt = "mid_level_reverse"
normalized = normalize_params(params)
raw = f"{normalized}|{timestamp}|{token}|{salt}"
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def build_payload(params: dict, token: str) -> dict:
timestamp = int(time.time() * 1000)
sign = build_sign(params, timestamp, token)
return {
"data": params,
"timestamp": timestamp,
"sign": sign
}
if __name__ == "__main__":
token = "test_token_123"
params = {
"keyword": "逆向",
"page": 1,
"pageSize": 20
}
payload = build_payload(params, token)
print(payload)
示例三:浏览器中 hook fetch 观察参数生成结果
如果你还没定位到生成函数,不要急着读代码。先 hook 一下请求发送层,往往更快。
(function () {
const rawFetch = window.fetch;
window.fetch = async function (...args) {
const [url, options] = args;
console.log('[fetch url]', url);
if (options) {
console.log('[fetch options]', options);
if (options.body) {
try {
console.log('[fetch body raw]', options.body);
if (typeof options.body === 'string') {
try {
console.log('[fetch body json]', JSON.parse(options.body));
} catch (e) {}
}
} catch (e) {
console.warn('body parse error', e);
}
}
}
const resp = await rawFetch.apply(this, args);
return resp;
};
})();
这段代码适合在浏览器控制台执行。它的价值在于:
- 你能直接看到最终提交的 body
- 能快速识别
sign、timestamp、nonce - 能判断加密发生在 fetch 之前还是更早
示例四:hook 常见加密入口
如果你怀疑站点使用 CryptoJS,可以这么观察:
(function () {
if (!window.CryptoJS) {
console.warn('CryptoJS not found');
return;
}
const rawMD5 = CryptoJS.MD5;
CryptoJS.MD5 = function (data) {
console.log('[MD5 input]', data && data.toString ? data.toString() : data);
const result = rawMD5.apply(this, arguments);
console.log('[MD5 output]', result.toString());
return result;
};
console.log('CryptoJS.MD5 hooked');
})();
我自己在分析一些站点时,最常见的突破口就是这种“先 hook 再回溯调用栈”的方式。
因为你不需要先搞懂整套混淆代码,只需要让关键函数先现身。
从抓包到还原:一条更稳的实战路径
这一部分给出一套更贴近项目的操作顺序。
第一步:抓包确认最小目标
先明确三件事:
- 真正有价值的是哪个接口
- 哪个参数导致重放失败
- 请求依赖哪些 header/cookie
建议重点记录:
- URL
- Method
- Query 参数
- Body 结构
- 请求头
- 响应码和错误信息
如果接口返回类似:
sign errorinvalid tokentimestamp expiredillegal request
那排查范围会收缩得很快。
第二步:固定变量,制造对照组
这是很多人忽略但非常关键的一步。
例如同一个请求,尽量只改一个字段:
- 只改
page - 只改
keyword - 只改时间间隔
- 不改 cookie,只重放 body
观察哪些字段随之变化。
你想得到的结论
sign是否只和 body 相关?- 是否和 header 里的 token 相关?
- 是否和 cookie 绑定?
- timestamp 是否参与验签?
- nonce 是否必须唯一?
这一步其实是在做“黑盒因果分析”。
第三步:从发送层向前追溯
如果抓包看到最终发出的 body 里有:
{
"data": "abcxyz...",
"sign": "9f8e7d...",
"timestamp": 1680000000000
}
那你的追踪顺序最好是:
- 在
fetch/xhr.send处下断点 - 看是谁调用了它
- 回到上一层找到 body 构造位置
- 再往前看
sign是哪个函数返回的 - 再看这个函数的输入是谁传进去的
这个顺序比“全局搜 sign”通常更稳。
因为很多站点压缩后变量名根本不是 sign,而是 _0x3fa2b1 这种鬼名字。
第四步:抽离最小可运行单元
定位到核心函数后,不要急着整包复制。
先做最小抽离:
- 只保留生成参数所需的函数
- 逐步补齐依赖
- 每补一层就验证一次输出
这个阶段最怕“一次性复制一万行代码”,后面只会更乱。
第五步:做结果校验
参数还原成功,不是“看起来像”,而是要满足:
- 本地输出和浏览器输出一致
- 接口可成功返回业务数据
- 多组输入下都稳定有效
建议至少验证三组不同参数,不要只测一组就下结论。
常见坑与排查
这部分我尽量写得实战一点,因为很多时间都是花在这里。
1. 只抄了算法,没抄输入预处理
最典型的坑:
- 前端会先对参数排序
- 会过滤空值
- 布尔值转字符串
- 数字转字符串
- 数组先
JSON.stringify - 中文编码方式不同
最终表现就是:
“我明明也用了 MD5,为什么 sign 不一样?”
排查方式
把原始输入串打印出来,对比:
- 浏览器端签名前原文
- 本地还原签名前原文
不要只比最终 hash,要比 hash 之前的明文。
2. 忽略环境变量
比如某些站点会把这些值混进签名:
localStorage.tokendocument.cookienavigator.userAgentwindow.location.pathname
你如果只复制函数,不模拟环境,结果一定错。
排查方式
在关键函数里打印所有入参和依赖对象;
如果在 Node 中运行,逐项 mock:
global.window = {};
global.navigator = {
userAgent: 'Mozilla/5.0'
};
global.document = {
cookie: 'sessionid=abc123'
};
3. 时间戳误差导致失败
有些接口会校验时间窗口,比如 ±5 秒或 ±30 秒。
你本地生成后如果请求发慢了,或者机器时间漂移,就会失败。
排查方式
- 检查本机时间是否准确
- 确认时间单位是秒还是毫秒
- 观察服务端是否要求 UTC 格式或特定格式化字符串
4. 混淆代码里的“假入口”误导分析
有些站点故意在代码里放很多看起来像加密的函数:
- 假 MD5
- 假 base64
- 垃圾字符串数组
- 无意义控制流平坦化
如果你纯靠静态阅读,很容易被带偏。
排查方式
优先依赖运行时:
- 发送前断点
- hook 加密函数
- 查看调用栈
- 记录真实执行路径
我自己的经验是:
运行时证据 > 静态猜测。
5. WebAssembly 或 Worker 中执行
如果你在主线程怎么都搜不到关键逻辑,可能它根本不在主线程。
现象
- 页面 JS 很干净
- 只看到很短的调用代码
- 请求前有 wasm 初始化
- 或者有 worker 脚本加载
排查方向
- 关注
WebAssembly.instantiate - 关注
new Worker() - 抓取 worker 脚本
- 分析 wasm 导出函数和输入输出
6. 请求成功一次,后续大量失败
这通常不是“算法偶发错误”,而是系统层约束:
- token 过期
- nonce 重放
- IP/设备绑定
- 风控阈值
- cookie 状态失效
排查方式
分层验证:
- 参数是否一致
- cookie 是否更新
- token 是否轮换
- 请求频率是否过高
- 是否存在一次性挑战值
安全/性能最佳实践
即使是逆向分析脚本,也建议做工程化,不然后期维护会很痛苦。
1. 把“抓包验证”和“参数生成”解耦
推荐拆成两层:
- 参数层:只负责产出
sign/data/timestamp - 请求层:只负责发 HTTP 请求
这样当接口字段变化时,你只需要调整一层。
classDiagram
class ParamBuilder {
+normalize(params)
+buildSign(params, ts, token)
+buildPayload(params, token)
}
class HttpClient {
+setHeaders(headers)
+post(url, payload)
}
class ReverseService {
+query(params)
}
ReverseService --> ParamBuilder
ReverseService --> HttpClient
2. 日志要记录“签名前原文”
生产环境里最难排查的问题,就是“怎么突然签名错了”。
解决办法很简单:保留这些日志:
- 输入参数
- 规范化后的字符串
- 时间戳
- token 摘要
- 最终 sign
当然,敏感信息要脱敏,不要把完整凭证直接落盘。
3. 缓存可复用的上下文
如果某接口强依赖:
- token
- deviceId
- session
- 初始化指纹
那这些值可以做短期缓存,避免每次重新走完整流程。
这样既减轻页面负担,也减少环境初始化时间。
4. 评估容量与资源消耗
如果你用浏览器自动化批量生成参数,资源成本其实不低。
一个简单估算思路:
- 单个浏览器实例占用内存:200MB ~ 500MB
- 单台机器可稳定承载实例数:取决于 CPU/内存
- 单次参数生成耗时:几十毫秒到数秒不等
所以:
- 小规模验证:浏览器内复用最省事
- 中等规模任务:Node 抽离更划算
- 长期大规模:重写算法收益最高
不要一上来就“开 50 个无头浏览器”,很容易把自己机器先打趴。
5. 注意合法边界和敏感数据保护
这点必须明确:
- 仅在授权测试、企业内审、教学研究、靶场练习中使用
- 不要收集、传播、滥用他人账号凭证
- 不要把真实 cookie、token、私钥写进代码仓库
- 日志、样本、抓包文件要做脱敏
技术能力越强,越要有边界意识。
一个可落地的排查清单
如果你手头刚好有个还原不出来的接口,可以按这个顺序过一遍:
抓包层
- 是否确认了真正发请求的接口
- 是否识别出核心校验字段
- 是否记录了 header/cookie/token
对照实验层
- 是否只改一个参数做过对比
- 是否观察过 sign 与哪些字段同步变化
- 是否确认时间戳和 nonce 的作用
运行时层
- 是否 hook 了 fetch/xhr
- 是否尝试 hook 过 MD5/AES/RSA 等入口
- 是否查看了请求发送前的调用栈
抽离层
- 是否找到了签名前原文
- 是否复现了参数排序/过滤/编码规则
- 是否补齐了浏览器环境依赖
校验层
- 本地输出是否与浏览器一致
- 多组参数下是否都能成功
- 是否排除 token 过期和风控因素
这份清单的价值在于:
你不会一直在“是不是算法错了”这个单点里打转,而是能系统排查整条链路。
总结
前端加密接口的逆向,本质上不是“猜一个密文怎么来的”,而是还原一个参数生产系统。
中级阶段最值得建立的能力,是这条完整路径:
- 抓包定位关键接口与关键参数
- 通过运行时 hook 和断点找到真实生成点
- 分清算法、输入、环境三类依赖
- 抽离最小可运行逻辑进行本地复现
- 用多组样本验证稳定性,并逐步工程化
如果你只记住一个结论,我希望是这个:
参数还原成功的关键,不在于你认识多少加密算法,而在于你能否准确找到“签名前原文”和“真实依赖环境”。
最后给几个可执行建议:
- 先抓发送层,再追生成层,不要一上来就埋头读混淆代码
- 先验证可用,再考虑重写,别过早工程化
- 遇到不一致时,先比签名前原文,不要只盯最终 sign
- 把环境依赖当一等公民,很多失败根本不是算法问题
- 长期任务优先抽象参数层,后面维护成本会低很多
如果你已经能独立做抓包和基础断点,那么把这套方法练熟之后,处理大多数前端签名接口,成功率会明显提升。真正难的站点当然还会有,但至少你不会再“看着一串 sign 发呆”,而是知道该从哪里下手。