从抓包到还原签名:中级开发者实战 Web 逆向中的前端加密参数分析与自动化复现
很多中级开发者第一次接触 Web 逆向时,卡住的并不是“不会抓包”,而是抓到了请求,却发现接口参数里有一串看起来像随机值的 sign、token、nonce、t、xyz,直接重放请求必然失败。
这篇文章我不讲太玄的理论,而是按真实工作流带你走一遍:
- 先抓包定位关键请求
- 判断签名参数属于哪一类
- 回到前端 JS 中定位生成逻辑
- 抽离签名算法
- 用脚本自动化复现
文章目标读者是已经有一定前端、Python 或 Node.js 基础的开发者。默认你知道浏览器开发者工具怎么开,也知道 HTTP 请求长什么样。
提醒:本文内容用于合法授权的安全研究、接口联调、自动化测试与教学分析。不要用于未授权的数据抓取或绕过访问控制。
背景与问题
一个典型场景是这样的:
你在浏览器里看到某个接口请求如下:
POST /api/data/list HTTP/1.1
Host: example.com
Content-Type: application/json
X-Sign: 8f3c1b7d...
X-Timestamp: 1698650000
{"page":1,"size":20,"keyword":"book"}
你把这个请求复制到 Postman 或 Python 里,结果服务端返回:
{
"code": 403,
"message": "invalid sign"
}
这时常见问题有几个:
sign是怎么生成的?- 是不是和请求体、时间戳、Cookie 绑定?
- 是前端自己算的,还是服务端下发后再加工的?
- 该如何稳定自动化复现,而不是每次手动复制?
你真正要解决的,不只是“算出一个 sign”
更准确地说,你要回答这几个问题:
- 输入是什么:路径、参数、时间戳、随机数、设备指纹、Cookie?
- 变换过程是什么:排序、拼接、编码、摘要、加密、混淆?
- 运行环境是什么:浏览器、Node、Webpack 模块、运行时补环境?
- 校验边界是什么:时效、重放、登录态、Referer、UA、Header 绑定?
如果这四件事没梳理清楚,就算你暂时跑通了,也很容易第二天失效。
前置知识与环境准备
建议准备以下工具:
- 浏览器开发者工具(Chrome DevTools)
- 抓包工具:Charles / Fiddler / mitmproxy / Burp Suite
- Node.js 18+
- Python 3.10+
- 一个 JS 格式化工具:Prettier 或在线 beautify
- 可选:
jsdom、crypto-js、axios
安装基础环境:
npm init -y
npm install axios crypto-js
pip install requests
核心原理
前端加密参数分析,核心不是“解密”,而是还原客户端生成签名的流程。
我一般把这类参数分成 4 种:
-
明文拼接后摘要
- 例如
md5(path + timestamp + body + secret) - 最常见,最适合复现
- 例如
-
对象排序后序列化摘要
- 例如对参数按 key 排序,拼成 querystring 后再 hash
- 容易踩对象顺序和空值处理的坑
-
前端对称加密
- 例如 AES 加密后再 Base64
- 重点是 key、iv、mode、padding
-
混淆包装型
- 表面很复杂,实际底层还是
md5/sha256/aes/rsa - 常见于 Webpack 打包、字符串数组混淆、控制流平坦化
- 表面很复杂,实际底层还是
一个典型签名链路
flowchart LR
A[抓包定位请求] --> B[找出变化参数 sign/timestamp/nonce]
B --> C[全局搜索参数名]
C --> D[定位请求发起点]
D --> E[回溯签名函数]
E --> F[提取输入与算法]
F --> G[Node/Python 自动化复现]
G --> H[对比浏览器真实请求验证]
请求发起与签名生成的协作关系
sequenceDiagram
participant U as 用户操作
participant B as 浏览器前端
participant S as 签名函数
participant A as 接口服务端
U->>B: 点击查询
B->>S: 传入 path/body/timestamp
S-->>B: 返回 sign
B->>A: 携带 sign 发起请求
A->>A: 按相同规则校验
A-->>B: 返回业务数据
我们要找的,不是“复杂代码”,而是“稳定输入输出关系”
比如下面这种代码看着复杂,其实很普通:
function getSign(path, data, ts) {
const raw = path + "|" + JSON.stringify(data) + "|" + ts + "|salt123";
return md5(raw);
}
真正难的是它可能被包装成:
- Webpack 模块
- 匿名闭包
- 动态字符串索引
- Hook 之后才出现的运行时代码
- 依赖浏览器环境,如
window.navigator、document.cookie
分析路径:从抓包到定位签名函数
这一部分是实战里最值钱的,因为很多人不是不会写代码,而是不会找入口。
第一步:抓包,找“会变”的字段
先连续发起 2~3 次相同请求,对比这些字段:
- Query 参数
- Request Body
- Header
- Cookie
- URL Path
重点找:
- 每次都变:
timestamp、nonce - 请求内容变时才变:
sign、token - 登录切换时变化:
session、authorization
如果你看到这种规律:
| 字段 | 请求 1 | 请求 2 | 规律 |
|---|---|---|---|
timestamp | 1698650000 | 1698650012 | 明显时间戳 |
nonce | a8f1... | c2b7... | 随机数 |
sign | 9c7d... | f112... | 依赖前两者或请求体 |
那就说明签名大概率依赖:
- 请求参数
- 时间戳
- 随机数
- 固定盐值
第二步:在 Sources 中全局搜索关键字
在浏览器 DevTools 中优先搜:
signtimestampnonce- 请求路径片段,例如
/api/data/list md5、sha1、sha256CryptoJSaxios.interceptors.request.use
很多签名并不在业务代码里直接调用,而是放在:
- axios 请求拦截器
- 公共请求封装函数
- 某个 util 模块
例如:
axios.interceptors.request.use((config) => {
const ts = Date.now().toString();
const sign = makeSign(config.url, config.data, ts);
config.headers["X-Timestamp"] = ts;
config.headers["X-Sign"] = sign;
return config;
});
那就已经快找到核心了。
第三步:打断点,看真实入参
如果全局搜索搜到很多结果,最有效的方法不是盲读,而是下断点。
建议断在:
- 请求拦截器
XMLHttpRequest.sendfetch- 目标工具函数入口
你要确认这些内容:
- 最终参与签名的是原始对象还是序列化字符串?
JSON.stringify之前是否排序?- 是否加了固定前后缀?
- 是否调用了 URL 编码?
- 时间戳单位是秒还是毫秒?
我当时踩过一个非常典型的坑:页面里展示的是秒级时间戳,但签名函数内部用的是毫秒级,最后服务端只取前 10 位。你如果直接拿前端 Header 里的秒级值参与计算,永远对不上。
实战示例:还原一个典型签名
下面构造一个常见案例,流程足够贴近真实项目,同时代码可运行。
目标请求
POST /api/data/list
X-Timestamp: 1698650000
X-Sign: 6d3c...
Content-Type: application/json
{"page":1,"size":20,"keyword":"book"}
通过调试定位到前端代码
假设你在前端里找到了这样一段逻辑:
function sortObject(obj) {
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
const val = obj[key];
if (val !== undefined && val !== null && val !== "") {
acc[key] = val;
}
return acc;
}, {});
}
function makeSign(url, data, ts) {
const payload = sortObject(data);
const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=demo_secret`;
return md5(raw).toUpperCase();
}
这就是一个非常典型的“排序 + 序列化 + 固定盐 + MD5”。
实战代码(可运行)
下面分别给出 Node.js 和 Python 版本,便于你做自动化复现。
Node.js 版:生成签名并发起请求
const axios = require("axios");
const CryptoJS = require("crypto-js");
function sortObject(obj) {
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
const val = obj[key];
if (val !== undefined && val !== null && val !== "") {
acc[key] = val;
}
return acc;
}, {});
}
function makeSign(url, data, ts) {
const payload = sortObject(data);
const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=demo_secret`;
return CryptoJS.MD5(raw).toString().toUpperCase();
}
async function main() {
const url = "/api/data/list";
const body = {
page: 1,
size: 20,
keyword: "book"
};
const ts = Math.floor(Date.now() / 1000).toString();
const sign = makeSign(url, body, ts);
console.log("timestamp:", ts);
console.log("sign:", sign);
const resp = await axios.post("https://example.com/api/data/list", body, {
headers: {
"Content-Type": "application/json",
"X-Timestamp": ts,
"X-Sign": sign,
"User-Agent": "Mozilla/5.0"
},
timeout: 10000
});
console.log(resp.data);
}
main().catch(err => {
if (err.response) {
console.error("status:", err.response.status);
console.error("data:", err.response.data);
} else {
console.error(err.message);
}
});
Python 版:复现同样签名逻辑
import time
import json
import hashlib
import requests
def sort_object(obj: dict) -> dict:
result = {}
for key in sorted(obj.keys()):
val = obj[key]
if val is not None and val != "":
result[key] = val
return result
def make_sign(url: str, data: dict, ts: str) -> str:
payload = sort_object(data)
raw = f'{url}?{json.dumps(payload, ensure_ascii=False, separators=(",", ":"))}&t={ts}&key=demo_secret'
return hashlib.md5(raw.encode("utf-8")).hexdigest().upper()
def main():
url = "/api/data/list"
body = {
"page": 1,
"size": 20,
"keyword": "book"
}
ts = str(int(time.time()))
sign = make_sign(url, body, ts)
print("timestamp:", ts)
print("sign:", sign)
resp = requests.post(
"https://example.com/api/data/list",
json=body,
headers={
"Content-Type": "application/json",
"X-Timestamp": ts,
"X-Sign": sign,
"User-Agent": "Mozilla/5.0"
},
timeout=10
)
print(resp.status_code)
print(resp.text)
if __name__ == "__main__":
main()
逐步验证清单
不要一上来就跑完整自动化。更稳妥的方式是按下面步骤验证。
验证 1:只校验签名函数输入输出
先固定一组参数,手工比对:
const url = "/api/data/list";
const body = { page: 1, size: 20, keyword: "book" };
const ts = "1698650000";
console.log(makeSign(url, body, ts));
目标是让你脚本的输出与浏览器中的真实 X-Sign 完全一致。
验证 2:确认序列化格式
重点检查:
- JSON 是否有空格
- key 顺序是否一致
- 中文是否转义
- 布尔值是否转成字符串
- 数字是否被字符串化
例如 Python 默认 json.dumps 会带空格,如果前端没有空格,你就要用:
json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
验证 3:确认 Header 与 Cookie 绑定
有些服务端并不是只校验 sign,还会同时校验:
CookieUser-AgentOriginReferer- 登录 token
这时签名对了,请求仍可能失败。
验证 4:确认时间窗
很多接口会限制:
- 时间戳偏差不能超过 5 分钟
nonce不能重复- 同一登录态重放被拒绝
更复杂场景:Webpack 打包与混淆代码怎么处理
如果你看到的是类似下面这种东西:
var _0x2f13 = ["MD5", "stringify", "headers", "X-Sign"];
function _0xabc(a, b) { ... }
先别慌,常见处理方式是:
- 先格式化代码
- 找到请求发起位置
- 围绕请求栈回溯
- 打印中间变量,而不是试图一次性读懂全文件
一个可行的策略
flowchart TD
A[找到接口调用位置] --> B[定位拦截器或公共请求函数]
B --> C[观察 sign 在哪一行赋值]
C --> D[向上回溯依赖函数]
D --> E[插桩 console.log 中间值]
E --> F[确认最终摘要或加密算法]
F --> G[抽离最小可运行代码]
插桩比死读代码更高效
例如你定位到:
config.headers["X-Sign"] = h(n(u(config.data), Date.now()));
与其猜 h/n/u 分别干了什么,不如临时改成:
const a = u(config.data);
console.log("u(data) =", a);
const b = n(a);
console.log("n(u(data)) =", b);
const c = h(b, Date.now());
console.log("final sign =", c);
config.headers["X-Sign"] = c;
这样你会很快看出:
u是排序n是 JSON 序列化h是 MD5 或 AES
如果依赖浏览器环境,如何补环境复现
有些签名函数不能直接拷到 Node 里跑,原因是它依赖:
windowdocumentnavigatorlocationatob/btoalocalStorage
这时可以做“最小补环境”。
简单补环境示例
global.window = global;
global.navigator = {
userAgent: "Mozilla/5.0"
};
global.document = {
cookie: "sessionid=demo"
};
global.location = {
href: "https://example.com/page"
};
function btoa(str) {
return Buffer.from(str, "binary").toString("base64");
}
function atob(str) {
return Buffer.from(str, "base64").toString("binary");
}
global.btoa = btoa;
global.atob = atob;
如果依赖更重,可以使用 jsdom:
npm install jsdom
const { JSDOM } = require("jsdom");
const dom = new JSDOM(`<!DOCTYPE html><p>Hello</p>`, {
url: "https://example.com"
});
global.window = dom.window;
global.document = dom.window.document;
global.navigator = dom.window.navigator;
global.location = dom.window.location;
但我的建议是:优先抽离纯算法,补环境只是兜底方案。
常见坑与排查
这一节我尽量说得接地气一点,因为这些坑几乎每个人都会踩。
1. 明明算法一样,结果就是不对
优先检查:
- 参数顺序是否一致
- 空值字段是否被过滤
- 时间戳位数是否一致
- 路径是否包含 query
- 大小写是否一致
- 摘要结果是否转大写
比如:
md5(raw).toUpperCase()
你漏了 toUpperCase(),结果一定不一致。
2. Python 和 JS 算出来不同
最常见原因是 JSON 序列化差异。
JS
JSON.stringify({a:1,b:2})
Python 默认
json.dumps({"a": 1, "b": 2})
Python 默认会输出带空格的字符串,和 JS 常常不一致。
正确写法一般要改成:
json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
3. 接口偶尔成功,偶尔失败
这种情况大概率不是算法错,而是时效或上下文依赖问题。
排查顺序:
- 时间戳是否过期
nonce是否复用- Cookie 是否失效
- 登录 token 是否过期
- 某些 header 是否缺失
- 是否需要先访问首页拿动态变量
4. 浏览器里能调,脚本里 403
这通常说明服务端做了环境校验,不只是签名校验。
重点检查:
OriginRefererUser-Agent- Cookie
- TLS 指纹差异
- 是否需要特定请求顺序
有些站点会要求你先加载某个初始化接口,拿到动态盐值再请求正式接口。
5. 看到 AES / RSA 就慌了
其实不一定复杂。
- AES:看 key、iv、mode、padding
- RSA:通常用于加密某个短字段,不适合大数据
- 混合方案:随机 AES key + RSA 加密 AES key
很多时候,你真正需要复现的不是“解密所有内容”,而只是“生成前端请求要求的那个密文参数”。
安全/性能最佳实践
做自动化复现时,很多人只关心“跑通”,但中级开发者更应该考虑稳定性和边界。
安全实践
-
只在合法授权范围内分析
- 用于自家系统联调、安全测试、教学研究
- 不要用于未授权绕过
-
不要硬编码敏感凭据到仓库
- Cookie
- token
- 私钥
- 动态盐值
建议使用环境变量:
export API_COOKIE="sessionid=xxxx"
const cookie = process.env.API_COOKIE || "";
- 保留最小化数据
- 调试日志不要把完整用户隐私数据打印到控制台
- 落盘时做脱敏
性能实践
-
签名函数做纯函数化
- 输入明确
- 无副作用
- 更方便单测与复用
-
避免每次重新解析整份前端代码
- 一旦抽离出算法,单独封装模块
- 不要每次启动都加载整个浏览器环境
-
缓存可复用上下文
- 例如动态配置、公共 token、静态盐值
- 但不要缓存短时效 nonce
-
加重试但别盲重试
- 403/签名错误通常不是网络问题
- 先重算签名,再决定是否重试
一个更工程化的封装方式
const CryptoJS = require("crypto-js");
class SignClient {
constructor(secret) {
this.secret = secret;
}
sortObject(obj) {
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
const val = obj[key];
if (val !== undefined && val !== null && val !== "") {
acc[key] = val;
}
return acc;
}, {});
}
makeSign(url, data, ts) {
const payload = this.sortObject(data);
const raw = `${url}?${JSON.stringify(payload)}&t=${ts}&key=${this.secret}`;
return CryptoJS.MD5(raw).toString().toUpperCase();
}
buildHeaders(url, data) {
const ts = Math.floor(Date.now() / 1000).toString();
return {
"X-Timestamp": ts,
"X-Sign": this.makeSign(url, data, ts)
};
}
}
module.exports = SignClient;
这样后面你做接口测试、压测、回归验证时都可以复用。
调试建议:建立“对照组”思维
我个人很推荐一个办法:每次只改一个变量,然后和浏览器真实请求做对照。
建议记录下面这张表:
| 项目 | 浏览器真实值 | 脚本值 | 是否一致 |
|---|---|---|---|
| URL | /api/data/list | /api/data/list | ✅ |
| Body 字符串 | {"keyword":"book","page":1,"size":20} | … | |
| Timestamp | 1698650000 | … | |
| Raw 拼接串 | ... | … | |
| Sign | 6D3C... | … |
只要你把 raw 拼接串 对齐,后面的 hash 基本就没问题了。
一个简单的状态视图:你现在卡在哪一步
stateDiagram-v2
[*] --> 抓包完成
抓包完成 --> 找到变化字段
找到变化字段 --> 定位请求发起点
定位请求发起点 --> 找到签名函数
找到签名函数 --> 提取输入输出
提取输入输出 --> 本地复现成功
本地复现成功 --> 自动化请求成功
自动化请求成功 --> [*]
找到签名函数 --> 依赖浏览器环境
依赖浏览器环境 --> 补环境或插桩
补环境或插桩 --> 提取输入输出
总结
从抓包到还原签名,本质上不是比谁“会解密”,而是比谁能更快建立这条链路:
- 抓包找变化字段
- 在前端代码中定位请求入口
- 回溯到签名函数
- 确认输入、序列化、摘要/加密方式
- 最小化抽离并自动化复现
- 用真实请求做逐项对照验证
如果你是中级开发者,我给你的可执行建议是:
- 先会看差异,再读代码
- 先对齐 raw 字符串,再谈算法
- 先抽纯函数,再考虑补环境
- 先做最小可运行复现,再工程化封装
最后强调边界条件:
- 如果签名强依赖设备指纹、动态下发密钥、WASM、行为校验或风控链路,那么“纯还原 sign”可能还不够。
- 这类场景就不能只盯着一个参数,而要看整条请求上下文。
但对绝大多数常规 Web 前端签名来说,只要方法对,路径其实是稳定的。别被混淆代码吓住,很多复杂外壳下面,仍然只是排序、拼接、摘要这几个老朋友。