背景与问题
做 Web 逆向时,最常见的场景不是“页面看不懂”,而是“接口明明看到了,但请求就是复现不出来”。
尤其是带前端加密的网站,XHR 请求里通常会出现这些典型特征:
sign、token、cipher、t、nonce这类动态参数- 请求体字段经过
Base64、AES、RSA、Hex、URL Encode多层包装 - 请求头里夹带设备指纹、时间戳、校验串
- 同一个接口,复制 curl 后重放失败,提示签名错误、参数非法、请求过期
我自己第一次碰这类接口时,最大的误区就是:盯着请求本身看太久,却没有回到“这个参数是谁生成的”。
所以这篇文章不只是讲抓包,而是从“架构视角”把整个定位链路梳理清楚:从 XHR 抓包,到加密函数追踪,再到关键参数还原,最终实现接口复现。
本文默认读者已经会基本的浏览器开发者工具、JavaScript 调试和 Python 请求发送。
背心问题的本质:不是“接口难”,而是“参数生成链路被隐藏”
先把问题抽象一下。一个前端加密接口,本质上通常包含三层:
- 业务参数层:真正要提交的数据,比如关键词、页码、用户动作
- 变换层:序列化、排序、拼接、摘要、加密
- 传输层:XHR/fetch 发请求,附加 headers、cookies、trace 信息
真正让接口无法复现的,往往不是网络层,而是中间的变换层。
flowchart LR
A[页面操作] --> B[业务参数组装]
B --> C[前端变换/签名]
C --> D[XHR或fetch发送]
D --> E[服务端验签/解密]
E --> F[业务响应]
我们抓到的请求,只是链路末端的产物。
要复现,就得反推回去:
- 哪些字段是固定的?
- 哪些字段是动态生成的?
- 动态字段依赖时间、随机数、cookie,还是隐藏常量?
- 加密前的原文是什么?
- 加密算法在浏览器里跑,还是 WebAssembly 里跑?
核心原理
1. 从网络请求倒推参数生成链
面对一个失败的复现请求,我一般按下面顺序拆:
第一步:识别“业务字段”和“安全字段”
例如一个常见请求体可能长这样:
{
"q": "laptop",
"page": 1,
"t": "1700000123",
"nonce": "9f2c7f6d",
"sign": "b8e1c5..."
}
这里:
q、page多半是业务字段t、nonce、sign多半是安全字段
经验上,以下字段值得优先怀疑:
- 明显短而随机:
nonce、salt - 时间相关:
t、ts、timestamp - 长十六进制/长 Base64:
sign、token、data - 请求头自定义字段:
x-sign、x-token、x-trace
第二步:验证字段是否参与签名
一个简单方法是:
- 抓一组成功请求
- 修改业务参数后重放
- 看服务端报错是“业务错误”还是“签名错误”
如果改了 q 后就签名失败,说明 q 在签名串里。
如果只改 page 还能成功,说明可能没参与,或者参与方式特殊。
第三步:用“全局搜索 + 断点”找参数生成点
定位顺序我通常这么走:
- 在 DevTools 的
Network里找到 XHR/fetch 请求 - 查看
Initiator,找到触发请求的脚本 - 全局搜索字段名,比如
sign、接口路径、固定请求头名 - 对以下位置打断点:
XMLHttpRequest.prototype.sendfetchCryptoJS常见函数atob/btoaJSON.stringifyencodeURIComponent
这一步的目标不是马上看懂所有代码,而是先抓住调用栈。
2. 常见前端加密模式
前端“加密”很多时候其实不是严格意义上的安全加密,而是接口参数混淆或签名校验。常见模式有:
模式 A:拼接后哈希
sign = md5(path + timestamp + nonce + payload + secret)
特点:
sign往往是 32 位 hex- 算法简单,藏的是
secret和拼接顺序
模式 B:对象排序后签名
keys sort -> k1=v1&k2=v2 -> sha256(...)
特点:
- 参数顺序敏感
undefined、空字符串、数字转字符串都可能影响结果
模式 C:请求体整体加密
data = AES(JSON.stringify(payload), key, iv)
特点:
- 请求体只有一个密文字段
- 重点在于找
key、iv、模式(CBC/ECB)和填充方式
模式 D:RSA 包装 AES 密钥
特点:
- 业务数据 AES 加密
- AES key 再用 RSA 公钥加密
- 看起来复杂,但前端一定要拿到加密逻辑,照样能还原流程
模式 E:运行时动态混淆
特点:
- 变量名全被压扁
- 字符串表偏移
- 甚至放进 WebAssembly
- 这时重点从“阅读源码”转向“截获运行时输入输出”
3. 架构视角:定位策略的取舍
如果把 Web 逆向当成一个工程问题,而不是一次性脚本问题,会更稳。
方案一:纯抓包重放
优点
- 上手快
- 适合参数短时有效、校验弱的站点
缺点
- 一旦签名时效短,就很不稳定
- 无法规模化
- 页面版本一更新就失效
方案二:浏览器内补环境调用原函数
优点
- 复用页面现成逻辑
- 适合复杂混淆、难读代码
缺点
- 依赖浏览器环境
- 自动化部署复杂
- 性能一般
方案三:脱离浏览器还原算法
优点
- 可控、可测试、可批量运行
- 更适合长期维护
缺点
- 前期分析成本高
- 一些环境依赖要手工补齐
我的经验是:
- 临时验证:先用浏览器内调用
- 稳定复现:再把核心算法迁到 Python/Node
- 长期维护:把参数生成做成独立模块,和采集逻辑解耦
flowchart TD
A[发现接口] --> B{目标是什么}
B -->|快速验证| C[抓包重放]
B -->|看懂签名| D[浏览器调试]
B -->|长期复用| E[脱离浏览器还原]
C --> F[是否稳定]
F -->|否| D
D --> G[参数生成链清晰]
G --> E
E --> H[形成可维护模块]
实战代码(可运行)
下面我用一个可运行的最小案例演示完整过程。
这个例子不是某真实站点代码,而是把真实分析中最常见的逻辑抽象出来:时间戳 + nonce + JSON 序列化 + SHA256 签名。
目标请求格式如下:
{
"data": {
"q": "phone",
"page": 1
},
"t": 1700000123,
"nonce": "ab12cd34",
"sign": "..."
}
签名规则:
sign = SHA256(JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret)
1. 前端示例代码:模拟页面里的加密逻辑
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>demo</title>
</head>
<body>
<script>
async function sha256(text) {
const enc = new TextEncoder().encode(text);
const buf = await crypto.subtle.digest("SHA-256", enc);
return Array.from(new Uint8Array(buf))
.map(x => x.toString(16).padStart(2, "0"))
.join("");
}
function randomNonce(len = 8) {
const chars = "abcdef0123456789";
let s = "";
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
async function buildPayload(q, page) {
const secret = "demo_secret_v1";
const data = { q, page };
const t = Math.floor(Date.now() / 1000);
const nonce = randomNonce();
const raw = JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret;
const sign = await sha256(raw);
return { data, t, nonce, sign };
}
async function sendRequest() {
const payload = await buildPayload("phone", 1);
console.log("request payload =>", payload);
const res = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
console.log(await res.text());
}
sendRequest();
</script>
</body>
</html>
这个例子里,逆向的关键点是:
secretJSON.stringify(data)的格式t和nonce- 最终
raw拼接顺序
2. Node.js 复现签名逻辑
如果你已经在前端代码里定位到上述逻辑,就可以先在 Node 里独立复现。
const crypto = require("crypto");
function signPayload(data, t, nonce, secret) {
const raw = JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret;
return crypto.createHash("sha256").update(raw).digest("hex");
}
const data = { q: "phone", page: 1 };
const t = 1700000123;
const nonce = "ab12cd34";
const secret = "demo_secret_v1";
const sign = signPayload(data, t, nonce, secret);
console.log({ data, t, nonce, sign });
运行:
node sign_demo.js
3. Python 复现并发送请求
实际项目里,很多人最后会用 Python 做接口自动化,所以这里给一个可直接运行的版本。
import json
import time
import random
import hashlib
import requests
SECRET = "demo_secret_v1"
def random_nonce(length=8):
chars = "abcdef0123456789"
return "".join(random.choice(chars) for _ in range(length))
def calc_sign(data, t, nonce, secret=SECRET):
raw = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + "|" + str(t) + "|" + nonce + "|" + secret
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def build_payload(q, page):
data = {"q": q, "page": page}
t = int(time.time())
nonce = random_nonce()
sign = calc_sign(data, t, nonce)
return {
"data": data,
"t": t,
"nonce": nonce,
"sign": sign
}
if __name__ == "__main__":
payload = build_payload("phone", 1)
print("payload =", payload)
# 示例地址,替换成真实目标
url = "https://example.com/api/search"
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
}
# 演示请求发送;真实环境按需启用
# resp = requests.post(url, headers=headers, json=payload, timeout=10)
# print(resp.status_code, resp.text)
这里有一个非常关键的细节:
我在 json.dumps 里用了 separators=(",", ":"),这是为了尽量贴近浏览器 JSON.stringify 的输出。
很多签名失败,问题就出在空格、字段顺序、编码格式上。
4. 如何在浏览器里快速截获签名原文
当代码混淆严重时,我经常不急着“看懂”,而是先截输入输出。
下面这段脚本可以在控制台里临时 hook fetch,观察请求体。
(function () {
const rawFetch = window.fetch;
window.fetch = async function (...args) {
const [url, options] = args;
console.log("fetch url:", url);
if (options && options.body) {
console.log("fetch body:", options.body);
}
const res = await rawFetch.apply(this, args);
return res;
};
})();
如果站点使用 XMLHttpRequest,可以这样 hook:
(function () {
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._method = method;
this._url = url;
return rawOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
console.log("xhr:", this._method, this._url);
console.log("xhr body:", body);
return rawSend.apply(this, arguments);
};
})();
进一步,如果你怀疑使用了 CryptoJS.SHA256 之类的函数,也可以做更细粒度的 hook。
思路是一样的:记录传入参数和返回值,比硬啃混淆代码高效得多。
从抓包到还原:一条可执行的定位路径
这一段我按实战顺序总结成流程,适合你拿去直接套。
sequenceDiagram
participant U as 用户操作
participant B as 浏览器页面
participant S as 签名函数
participant X as XHR/fetch
participant R as 服务端
U->>B: 点击搜索
B->>S: 组装 data/t/nonce
S->>S: 计算 sign / 加密 data
S->>X: 返回最终请求参数
X->>R: 发送请求
R->>R: 验签/解密
R-->>X: 返回结果
X-->>B: 页面渲染
建议按下面 7 步走:
- 在 Network 中锁定目标请求
- 记下请求方法、路径、headers、body 格式
- 区分业务字段和动态安全字段
- 从 Initiator 或全局搜索进入源码
- 在发送点附近打断点,看调用栈
- 找到最终签名输入原文
- 在 Node/Python 中最小化复现,逐步对齐
这里最重要的一句话是:
先对齐“签名前原文”,再对齐“算法实现”。
因为很多时候算法不复杂,复杂的是原文构造过程,比如:
- 字段是否排序
- 数字是否转字符串
null是否参与拼接- URL 是否先编码再签名
- body 是对象还是字符串
常见坑与排查
这部分我尽量写得接地气一点,很多问题我自己都踩过。
1. 明明算法对了,签名还是错
优先检查这几项:
JSON.stringify输出是否一致- 字段顺序是否一致
- 是否少了某个隐藏常量
- 时间戳单位是秒还是毫秒
- nonce 是否长度固定
- 十六进制输出大小写是否敏感
典型排查办法
把浏览器内生成的原文和你本地生成的原文都打印出来,一字符一字符比。
print(repr(raw))
很多时候差的不是算法,而是一个空格、一个分隔符、一个大小写。
2. 抓包里看到的是密文,找不到明文
这通常说明你观察得太晚了。
请求发出去时已经加密完成,所以应该往前追:
- 在
send前断点 - 在加密函数入口断点
- 在
JSON.stringify、encrypt、digest一类函数上断点
如果站点启用了 source map,直接看源码会轻松很多;没有的话就靠调用栈定位。
3. 请求复现偶发成功、偶发失败
这种情况大概率与“时效性”有关:
- 时间戳过期
- nonce 重复
- token 与 cookie 绑定
- 签名依赖某个会变化的 header
建议做三件事:
- 抓多组请求,对比变化字段
- 把 cookie、headers、payload 一起存档
- 观察成功请求的时间窗口
4. Python 复现总是和浏览器不同
我见过最常见的原因有两个:
原因 A:序列化不一致
json.dumps(data)
默认输出可能包含空格,而前端 JSON.stringify 没有。
应改成:
json.dumps(data, separators=(",", ":"), ensure_ascii=False)
原因 B:字符编码不一致
浏览器一般是 UTF-8。
如果你本地拼接或转码时混入了其他编码,摘要结果一定不一样。
5. 遇到混淆、压缩、甚至 WebAssembly 怎么办
不要一开始就想着完整反编译。
中级阶段最有效的策略仍然是:
- 找输入输出
- 找调用链
- 找边界函数
- 在运行时截获参数
尤其是 WebAssembly 场景,很多人会被“看不懂”吓住。
其实如果最终请求还要经过 JS 层,仍然可以从 JS-WASM 边界截获参数。
flowchart LR
A[业务数据] --> B[JS层预处理]
B --> C[WASM/混淆函数]
C --> D[返回sign或密文]
D --> E[XHR/fetch发送]
安全/性能最佳实践
虽然本文重点是逆向分析,但从架构角度,也有必要说清楚边界:
前端加密不是万能防护,更多是增加门槛,而不是建立真正的信任。
1. 安全上的正确认知
前端密钥不可作为绝对秘密
只要密钥在前端运行环境里可达,就存在被提取的可能。
因此:
- 前端签名适合作为“防滥用”措施
- 不适合作为高价值安全边界的唯一手段
服务端必须二次校验
至少应校验:
- 时间戳有效期
- nonce 去重
- 用户态 token
- 频率限制
- 风控策略
不要把真正敏感逻辑完全押在前端
比如:
- 权限判定
- 核心价格计算
- 高价值数据解密
这些必须由服务端掌控。
2. 逆向复现代码的工程化建议
如果你是做内部测试、协议分析或自动化验证,建议把代码分层:
client.py:发请求sign.py:参数生成models.py:数据结构tests/:签名回归测试
一个简单的模块划分示例
# sign.py
import json
import hashlib
def calc_sign(data, t, nonce, secret):
raw = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + "|" + str(t) + "|" + nonce + "|" + secret
return hashlib.sha256(raw.encode()).hexdigest()
# client.py
import time
import random
import requests
from sign import calc_sign
SECRET = "demo_secret_v1"
def random_nonce(length=8):
chars = "abcdef0123456789"
return "".join(random.choice(chars) for _ in range(length))
def search(q, page):
data = {"q": q, "page": page}
t = int(time.time())
nonce = random_nonce()
sign = calc_sign(data, t, nonce, SECRET)
payload = {
"data": data,
"t": t,
"nonce": nonce,
"sign": sign
}
return requests.post("https://example.com/api/search", json=payload, timeout=10)
这样做的好处是:
页面逻辑更新时,你只需要改签名模块,不会牵一发动全身。
3. 性能上的取舍
本地复现优于浏览器驱动复现
如果只是要批量调用接口,能脱离浏览器就尽量脱离:
- 资源占用更低
- 并发更高
- 稳定性更好
但复杂环境不要过早“纯净化”
如果接口依赖:
- 浏览器指纹
- Canvas/WebGL
- 特定 window 属性
- 某些运行时对象
那就别一上来追求纯 Python。
先在浏览器内把逻辑跑通,再决定哪些部分值得迁移。
方案对比与取舍分析
从架构角度看,接口复现并不是只有“能不能做到”,还涉及维护成本。
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 直接抓包重放 | 临时验证 | 快速、简单 | 稳定性差 |
| 浏览器 hook 调原函数 | 混淆重、逻辑复杂 | 成功率高 | 部署重、性能一般 |
| Node/Python 还原算法 | 长期维护 | 可测试、易集成 | 前期分析成本高 |
| 混合方案 | 中大型项目 | 灵活、渐进演化 | 需要更好工程管理 |
如果团队需要长期维护某类协议,我建议采用混合方案:
- 用浏览器 hook 快速验证
- 提取稳定参数生成链
- 将签名逻辑迁移到独立模块
- 建立回归测试,防止页面升级后静默失效
总结
Web 逆向里最难的,通常不是“抓不到请求”,而是“抓到了却复现不了”。
这类前端加密接口的破题关键,可以浓缩成三句话:
- 先区分业务参数和安全参数
- 先找到签名前原文,再分析算法
- 先跑通,再工程化重构
如果你正在实战中卡住,我建议按这个最小清单排查:
- 目标请求是否锁定正确
sign/t/nonce/token哪些是动态的- 签名原文是否已完整拿到
- 序列化、排序、编码是否对齐
- 是否存在时效、cookie、header 绑定
- 是否该从浏览器内调用过渡到本地还原
最后提醒一个边界:
本文讨论的是接口分析、调试与协议理解的方法论。实际使用时,应确保行为符合法律、授权与目标系统规则。
从技术上看,前端加密更像一道门槛;从工程上看,真正可持续的能力,是你能否把这道门槛拆解成可定位、可验证、可维护的参数生成链。