从抓包到还原签名链路:一次典型 Web 逆向中 JS 混淆、加密参数与接口复现的实战拆解
很多人刚接触 Web 逆向时,最容易卡住的不是“怎么抓包”,而是明明已经看到请求了,却复现不出来。
参数里有一串看不懂的 sign、token、encData,前端代码又经过混淆压缩,直接搜字段名几乎等于大海捞针。
这篇文章我不打算只讲概念,而是按一次典型实战链路来拆:
- 先抓到真实请求
- 再定位签名参数是在哪生成的
- 处理 JS 混淆,抽出核心算法
- 还原请求参数、加密逻辑与 headers
- 最后用代码把接口稳定复现出来
重点不是某个站点,而是一套可迁移的方法。只要目标站不是特别重的风控体系,这套思路大多数都能落地。
背景与问题
在典型的 Web 页面里,前端发起接口请求时,通常不会直接把原始参数送给后端,而是会附加一些保护字段,比如:
- 时间戳
ts - 随机串
nonce - 签名
sign - 加密体
data/encData - 设备指纹
fingerprint - 动态 token / session 派生值
如果只是看浏览器 Network 面板,我们能拿到结果,但拿不到生成过程。
真正难的部分往往是这些问题:
sign到底由哪些字段拼接?- 拼接顺序是固定还是排序?
- 有没有加盐?
- 是 MD5 / SHA1 / HMAC / RSA / AES,还是几种组合?
- 混淆后的 JS 入口在哪?
- 接口失败是签名错了,还是 cookie / header / 时序错了?
我当时第一次做这类分析时,最大的误区是:一上来就盯着所有混淆 JS 看。
后来发现,更高效的方式是反过来——从请求出发,倒推签名链路。
前置知识
如果你已经熟悉这些内容,可以直接跳到后面的实战部分。
建议至少具备以下基础:
- 会用浏览器开发者工具看 Network / Sources / Console
- 会简单读 JavaScript
- 知道常见哈希与对称加密的区别
- 能用 Python 或 Node.js 发送 HTTP 请求
环境准备
本文示例使用以下工具,任选替代品也行:
- Chrome DevTools
- Fiddler / Charles / mitmproxy(抓包可选)
- Node.js 16+
- Python 3.10+
- 一个格式化 JS 的工具:
- 浏览器 Pretty Print
js-beautify- 在线 AST 工具(如 AST Explorer)
安装 Python 依赖:
pip install requests pycryptodome
安装 Node 依赖(如果需要跑前端还原逻辑):
npm init -y
npm install crypto-js axios
整体思路:先定链路,再拆算法
很多逆向文章一上来就开始“扣代码”,但实战里更推荐分层推进:
flowchart TD
A[抓包定位目标接口] --> B[识别关键参数 sign ts nonce data]
B --> C[XHR/Fetch 断点定位调用栈]
C --> D[找到签名函数入口]
D --> E[处理 JS 混淆与抽取核心逻辑]
E --> F[验证参数拼接与加密过程]
F --> G[Python/Node 复现请求]
G --> H[稳定性验证与异常排查]
这个流程的核心原则是:
- 先缩小范围:只盯目标接口相关代码
- 先验证输入输出:不要一开始追所有函数细节
- 先复现最小闭环:能返回正确数据,再谈工程化
背景与问题:一个典型目标接口长什么样
假设我们抓到某站点的一个搜索接口:
POST /api/search HTTP/1.1
Host: example.com
Content-Type: application/json
X-Token: 7f3d...
User-Agent: Mozilla/5.0 ...
{
"q": "laptop",
"page": 1,
"ts": 1710000000000,
"nonce": "a8f1c2e9",
"sign": "a2c9e5b8d7f1...",
"data": "U2FsdGVkX1..."
}
服务端返回失败时是这样的:
{
"code": 403,
"msg": "invalid sign"
}
此时我们可以先建立几个假设:
sign依赖q/page/ts/noncedata可能是加密后的业务体X-Token也可能参与签名- 请求头顺序通常不参与,但某些 header 的值可能参与
- 后端有时间窗口校验,
ts不能复用太久
核心原理
一次典型 Web 接口保护,大致是以下几种模式之一:
1. 纯签名模式
业务参数明文传输,只额外增加一个摘要签名。
例如:
sign = md5("page=1&q=laptop&ts=1710000000000&nonce=a8f1c2e9&key=secret")
特点:
- 参数可见
- 重点是拼接顺序与盐值
- 很适合快速复现
2. 签名 + 对称加密模式
业务参数先加密,再配合签名校验。
例如:
data = AES(JSON.stringify(payload), key, iv)
sign = SHA256(data + ts + nonce + secret)
特点:
- 明文参数不直接发送
- 需要同时还原加密与签名
- 常见于中等强度保护
3. 动态派生模式
签名依赖运行时上下文,如:
- cookie 中某个字段
- 本地存储 token
- 页面初始化接口返回值
- JS 运行环境生成的指纹
特点:
- 单纯复制一段算法代码往往不够
- 需要补齐上下游依赖
先从抓包入手:锁定真正要分析的请求
抓包时不要见到请求就分析,先做筛选:
看这几个点
- 是否是业务核心接口,而不是埋点
- 请求失败时是否真的和签名相关
- 参数里是否存在明显动态值
- 接口触发时机是否稳定、可重复
具体做法
- 打开 DevTools 的 Network
- 过滤
Fetch/XHR - 触发一次页面操作
- 找到返回业务数据的那个请求
- 右键
Copy as fetch或Copy as cURL
如果直接复制请求就能复现,说明站点保护很弱,那就没必要过度逆向。
真正值得分析的是:复制原请求也只能成功一次,或者换参数后就失败。
定位签名入口:比“全文搜索 sign”更靠谱的方法
仅仅在 Sources 里搜 sign,通常命中一堆无关代码。更有效的手段有两个:
方法一:XHR / Fetch 断点
在 Chrome DevTools 中:
- Sources
- Event Listener Breakpoints
- 勾选
XHR/fetch
重新触发请求,浏览器会在发送前断住。
然后看调用栈(Call Stack),沿着栈往上翻,通常能定位到:
- 参数组装函数
- 签名函数入口
- 加密函数入口
方法二:重写关键函数做日志
在 Console 临时 hook:
(function () {
const originFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch args]', args);
return originFetch.apply(this, args);
};
const originOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
console.log('[xhr open]', args);
return originOpen.apply(this, args);
};
const originSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (...args) {
console.log('[xhr send]', args);
return originSend.apply(this, args);
};
})();
这段代码的作用不是直接拿到签名算法,而是先确认数据是在什么阶段被处理的:
send前 body 已经是密文了吗?- body 是字符串还是对象?
- header 是在哪一层附加的?
签名链路长什么样:建立“输入 -> 变换 -> 输出”模型
定位到关键函数后,不要马上逐行读。先做一张链路图,把输入输出梳理清楚。
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名模块
participant E as 加密模块
participant A as 接口服务端
U->>P: 输入搜索词/翻页
P->>P: 组装 payload
P->>S: 传入 q,page,ts,nonce,token
S-->>P: 返回 sign
P->>E: 传入 payload
E-->>P: 返回 data
P->>A: 发送 data, ts, nonce, sign
A-->>P: 返回业务结果
这一步特别重要。
因为很多混淆代码看起来很乱,但真正参与签名的只有一小撮变量。
你只要先把“谁输入,谁输出”搞清楚,后面剥代码会轻松很多。
处理 JS 混淆:先去噪,再抽核心
前端混淆最常见的几类形式:
- 变量名压缩:
_0x12ab - 字符串数组映射:
_0x3f2c(0x1a) - 控制流平坦化:
while(!![]){switch(...)} - 自执行包装:一层层 IIFE
- 大量无关 polyfill / 框架代码
第一步:格式化
先在浏览器里点击 Pretty Print,或者用工具美化。
目标不是“看懂所有代码”,而是让函数边界清晰。
第二步:识别可疑特征
重点盯这些行为:
JSON.stringifyObject.keys(...).sort()join('&')md5,sha1,sha256,hmacCryptoJSencrypt,decryptDate.now()Math.random()localStorage,sessionStorage,document.cookie
第三步:打印中间值
假设我们定位到这样一个函数:
function makeSign(payload, token) {
const ts = Date.now();
const nonce = randStr(8);
const sorted = Object.keys(payload)
.sort()
.map(k => `${k}=${payload[k]}`)
.join('&');
const raw = `${sorted}&ts=${ts}&nonce=${nonce}&token=${token}&key=abc123`;
const sign = md5(raw);
return { ts, nonce, sign };
}
即便它被混淆成很难看的样子,你要关心的仍然是:
payload是什么- 有没有排序
ts/nonce/token是否参与- 盐值是什么
- 最终哈希算法是什么
一个可运行的最小示例:还原签名函数
下面我们构造一个典型场景:
接口签名规则为:
- 业务参数按 key 排序
- 组成
k=v&k=v字符串 - 拼上
ts、nonce、token - 再拼一个固定密钥
- 最后做 MD5
Node.js 版本
const crypto = require('crypto');
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function buildQuery(obj) {
return Object.keys(obj)
.sort()
.map(key => `${key}=${obj[key]}`)
.join('&');
}
function makeSign(payload, token, ts, nonce) {
const base = buildQuery(payload);
const raw = `${base}&ts=${ts}&nonce=${nonce}&token=${token}&key=abc123`;
return md5(raw);
}
// demo
const payload = {
q: 'laptop',
page: 1
};
const token = 'user_token_xxx';
const ts = 1710000000000;
const nonce = 'a8f1c2e9';
const sign = makeSign(payload, token, ts, nonce);
console.log({
payload,
ts,
nonce,
sign
});
如果还有加密参数:AES 还原示例
有些站点不是直接提交明文参数,而是先把业务体 AES 加密,再计算签名。
下面给一个常见模式:
data = AES-CBC(JSON.stringify(payload), key, iv)sign = md5(data + ts + nonce + secret)
Node.js 版本
const crypto = require('crypto');
function aesEncrypt(text, key, iv) {
const cipher = crypto.createCipheriv(
'aes-128-cbc',
Buffer.from(key, 'utf8'),
Buffer.from(iv, 'utf8')
);
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
}
function md5(text) {
return crypto.createHash('md5').update(text, 'utf8').digest('hex');
}
function buildEncryptedRequest(payload, ts, nonce) {
const key = '1234567890abcdef';
const iv = 'abcdef1234567890';
const secret = 'server_secret';
const data = aesEncrypt(JSON.stringify(payload), key, iv);
const sign = md5(data + ts + nonce + secret);
return {
data,
ts,
nonce,
sign
};
}
const payload = {
q: 'laptop',
page: 1
};
console.log(buildEncryptedRequest(payload, 1710000000000, 'a8f1c2e9'));
Python 复现接口请求
实际工作里,我更常用 Python 做接口复现,因为方便串联测试、重试和数据处理。
Python 版本:签名 + 请求发送
import hashlib
import time
import random
import string
import requests
def md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def build_query(data: dict) -> str:
items = sorted(data.items(), key=lambda x: x[0])
return "&".join(f"{k}={v}" for k, v in items)
def random_nonce(length=8) -> str:
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
def make_sign(payload: dict, token: str, ts: int, nonce: str) -> str:
base = build_query(payload)
raw = f"{base}&ts={ts}&nonce={nonce}&token={token}&key=abc123"
return md5(raw)
def send_request():
url = "https://example.com/api/search"
payload = {
"q": "laptop",
"page": 1
}
token = "user_token_xxx"
ts = int(time.time() * 1000)
nonce = random_nonce(8)
sign = make_sign(payload, token, ts, nonce)
body = {
**payload,
"ts": ts,
"nonce": nonce,
"sign": sign
}
headers = {
"Content-Type": "application/json",
"X-Token": token,
"User-Agent": "Mozilla/5.0"
}
resp = requests.post(url, json=body, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
if __name__ == "__main__":
send_request()
Python 版本:AES 加密参数复现
import json
import time
import random
import string
import hashlib
import base64
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
def md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def random_nonce(length=8) -> str:
return "".join(random.choices(string.ascii_lowercase + string.digits, k=length))
def aes_encrypt(text: str, key: str, iv: str) -> str:
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
encrypted = cipher.encrypt(pad(text.encode("utf-8"), AES.block_size))
return base64.b64encode(encrypted).decode("utf-8")
def build_request(payload: dict):
ts = int(time.time() * 1000)
nonce = random_nonce(8)
key = "1234567890abcdef"
iv = "abcdef1234567890"
secret = "server_secret"
data = aes_encrypt(json.dumps(payload, separators=(",", ":")), key, iv)
sign = md5(data + str(ts) + nonce + secret)
return {
"data": data,
"ts": ts,
"nonce": nonce,
"sign": sign
}
def send_request():
url = "https://example.com/api/search"
payload = {
"q": "laptop",
"page": 1
}
body = build_request(payload)
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
}
resp = requests.post(url, json=body, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
if __name__ == "__main__":
send_request()
逐步验证清单
不要一口气写完整脚本后才测试。更稳的方式是按下面顺序逐步验证:
第 1 步:先复用抓包参数
- 直接把抓包里的原始 body 和 headers 拷贝到代码里
- 如果这样都失败,说明站点还依赖 cookie、referer、UA 或时间窗口
第 2 步:只替换一个字段
- 比如只改
page - 看错误从“成功”变成“invalid sign”
- 这说明签名确实覆盖了业务参数
第 3 步:手动验证签名函数
在浏览器 Console 中执行你抽出来的签名函数,比较:
- 浏览器生成的
sign - 你本地代码生成的
sign
必须完全一致。
第 4 步:再引入随机值生成
验证:
ts格式是否一致(秒 / 毫秒)nonce长度与字符集是否一致- 是否有 URL 编码或大小写转换
第 5 步:补齐加密体
如果有 data:
- 比较密文长度是否接近
- 比较 Base64 / Hex 编码格式
- 检查 JSON 是否压缩了空格
常见坑与排查
这是实战里最容易浪费时间的部分。我把高频问题按现象整理一下。
1. 签名算法对了,但结果还是不一致
常见原因:
- 参数排序方式不同
- 数字在 JS 里是 number,到了 Python 变成字符串
- 空值字段是否参与签名不一致
- Boolean 在不同语言里表现不同:
true/falsevsTrue/False - JSON 序列化格式不同,尤其空格和 key 顺序
排查建议:
payload = {"page": 1, "q": "laptop"}
print(sorted(payload.items(), key=lambda x: x[0]))
把原始拼接字符串打出来,而不是只看最终 hash。
2. 明明复制了浏览器请求,代码里还是 403
常见原因:
- cookie 缺失
- 某个动态 header 没带
- referer/origin 校验
- token 过期
- IP 风控
排查顺序建议:
- 对比浏览器与脚本完整请求
- 确认 cookie 是否必要
- 确认 token 是否页面初始化时更新
- 检查时间戳是否超时
3. AES 结果不一致
常见原因:
- CBC / ECB 模式搞错
- key、iv 长度不对
- 填充方式不同(PKCS7 / ZeroPadding)
- 输出格式不同(Base64 / Hex)
可用这张状态图梳理加密排查过程:
stateDiagram-v2
[*] --> 确认算法
确认算法 --> 确认模式
确认模式 --> 确认key_iv
确认key_iv --> 确认填充方式
确认填充方式 --> 确认输出编码
确认输出编码 --> 对比浏览器结果
对比浏览器结果 --> [*]
4. 代码抽出来运行就报错
这很常见,因为前端函数可能依赖浏览器环境:
windowdocumentnavigatoratob/btoalocalStorage
应对方式:
- 能重写就重写,不要整包搬运
- 必要时在 Node 里补 mock
- 优先抽“纯算法函数”,避免耦合环境
比如:
global.window = {};
global.navigator = { userAgent: 'Mozilla/5.0' };
global.atob = str => Buffer.from(str, 'base64').toString('binary');
global.btoa = str => Buffer.from(str, 'binary').toString('base64');
5. 找到函数了,但看不懂混淆变量
我的经验是:不要试图直接“看懂”,而是先把函数跑起来。
可以这样做:
function debugWrap(fn, name) {
return function (...args) {
console.log(`[${name}] args=`, args);
const ret = fn.apply(this, args);
console.log(`[${name}] ret=`, ret);
return ret;
};
}
然后把目标函数包起来观察入参与返回值。
很多时候你不需要彻底反混淆,只要能确认:
- 输入是什么
- 中间字符串是什么
- 最终输出是什么
就足够复现了。
一个更贴近真实场景的定位套路
如果你面对的是 Webpack 打包后的大站点,可以按下面这套路径走:
flowchart LR
A[Network 找到目标请求] --> B[查看 Initiator]
B --> C[定位到发请求模块]
C --> D[找请求拦截器/统一封装层]
D --> E[定位 sign/data 生成逻辑]
E --> F[抽出独立函数验证]
F --> G[脚本复现]
这里有个小经验非常实用:
优先找统一请求封装层
很多项目不会在每个页面单独写签名,而是统一封装在:
- axios interceptor
- request.js
- api client
- 通用 util 模块
也就是说,真正应该先找的不是业务页面,而是请求公共层。
如果你在调用栈里看见 axios.request、interceptors.request.use 之类的结构,十有八九就是突破口。
安全/性能最佳实践
这里的“最佳实践”不是教你“怎么绕过安全”,而是强调在分析与复现时,如何做到更稳、更安全、更节制。
1. 只做最小化验证
不要上来就高频并发请求。
先确认:
- 参数是否正确
- 请求是否稳定
- 是否存在时间窗口或限速
建议先低频单线程验证。
2. 保存中间产物
非常建议把这些内容记录下来:
- 原始请求体
- 原始签名串
- 中间加密前明文
- 最终加密结果
- 对应时间戳与 nonce
这样一旦失败,可以快速比对是哪一层出问题。
3. 抽象成可测试函数
不要把逻辑全堆在一个脚本里。
最好的结构是:
build_payload()encrypt_data()make_sign()send_request()
这样你每层都能单测。
4. 注意时效性
很多站点的签名依赖:
- 短期 token
- 页面初始化值
- session
- cookie
这类逻辑即便今天复现成功,过几天也可能失效。
所以最好把“获取前置令牌”的过程也纳入自动化。
5. 避免过度依赖整包前端代码
直接在 Node 里跑整个前端 bundle,短期看省事,长期非常脆弱:
- 依赖浏览器环境太多
- 版本更新容易崩
- 很难维护
更稳的做法是:把核心算法剥离成最小实现。
一个推荐的工程化目录
如果你打算把一次分析沉淀下来,建议按这种结构组织:
project/
├── samples/
│ ├── request_success.json
│ └── request_failed.json
├── js/
│ └── sign_extract.js
├── python/
│ ├── sign.py
│ └── client.py
├── docs/
│ └── notes.md
└── README.md
这样做的好处是:
- 样本、算法、请求逻辑分离
- 方便后续站点更新时快速定位差异
- 团队协作时也更清楚
边界条件:什么时候这套方法不够用
本文的方法适合大多数“前端生成签名”的常见场景,但它也有边界:
不太好直接套用的情况
- 签名依赖 WebAssembly
- 依赖浏览器指纹、Canvas、WebGL 等复杂环境
- 强风控绑定设备/IP/行为轨迹
- 请求前有多轮挑战校验
- 参数由 Native App 或插件生成,而非纯前端 JS
这种情况下,思路还是类似,但工具和工作量会明显上一个台阶。
总结
把一次 Web 逆向里的签名链路还原出来,核心不是“把所有混淆 JS 看懂”,而是这三件事:
- 从抓包结果倒推生成过程
- 只抓关键输入输出,不陷入无关代码
- 先做最小可运行复现,再逐步补齐上下游依赖
如果你只记住一个实战口诀,我建议是这句:
先抓请求,后断调用栈;先对输入输出,后拆混淆细节;先复现最小闭环,再谈稳定工程化。
最后给几个可执行建议:
- 遇到
sign不一致,第一时间打印“原始拼接串” - 遇到 AES 不一致,优先核对模式、填充、输出编码
- 遇到脚本 403,别只盯算法,要把 cookie、token、header、时效一起看
- 遇到混淆严重,优先用断点和 hook 找入口,而不是硬啃整包代码
只要你把“请求链路”拆成一个个可验证节点,Web 逆向这件事就会从“玄学”变成“调试工程”。
如果你正在做自己的练习,建议照着本文顺序完整走一遍:
抓包 → 定位请求入口 → 抽签名函数 → 对比中间值 → 脚本复现。
这一套打通一次,后面再碰到类似站点,效率会高很多。