Web逆向实战:从前端加密参数定位到接口签名算法复现的完整分析方法
做 Web 逆向时,最常见也最让人头疼的场景,不是“接口找不到”,而是接口找到了,请求也抓到了,但关键参数是加密的、签名是动态的、直接重放就是失败。
这篇文章我不打算只讲零散技巧,而是带你走一遍更稳定的方法论:如何从前端页面定位加密参数生成逻辑,再到把接口签名算法复现出来,最终用脚本稳定调用接口。如果你已经有一定 JavaScript 基础,也用过 DevTools、抓过包,这篇内容会比较适合你。
说明:本文讨论的是通用分析方法,适用于自有系统调试、安全测试、协议研究等合法场景。请勿用于未授权目标。
背景与问题
很多现代 Web 应用会在前端做这些事情:
- 对请求参数做加密,如 AES/RSA/自定义混淆
- 对请求体或查询串做签名,如
sign、token、x-sign - 加入时间戳、随机数、设备指纹等动态字段
- 对代码进行压缩、混淆、控制流平坦化,增加分析难度
于是我们经常会遇到这样的现象:
- 明明请求 URL 和 body 一模一样,重放却返回“签名错误”
- 某个参数每次都不同,看不出规律
- 浏览器里请求成功,脚本里请求失败
- 切换账号、切换环境后算法行为不一致
从经验上看,问题通常不在“不会写代码”,而在于定位链路不完整。很多人上来就盯着 sign,其实真正影响结果的,可能是:
- 请求参数经过了排序
- 某些字段在签名前被二次编码
- 时间戳单位是秒还是毫秒
- Header 里的某个值也参与签名
- 签名之前做了哈希,哈希之前又做了 JSON 序列化
- JSON 序列化键顺序与我们本地实现不一致
所以,本文重点不是“某个网站的某段代码”,而是一套可迁移的分析路径。
前置知识
在开始之前,建议你至少具备这些基础:
- 会使用 Chrome DevTools:Network、Sources、Debugger
- 看得懂基础 JavaScript
- 知道常见加密/摘要算法:MD5、SHA256、AES、RSA、HMAC
- 会用 Python 或 Node.js 写简单脚本
如果你对 JS 断点调试还不熟,优先练这几个动作:
- XHR / Fetch 断点
- 全局搜索关键字
- Call Stack 回溯调用链
- Local Scope / Closure 变量观察
- Override 或 Snippet 注入调试代码
环境准备
我平时会准备这样一套工具:
- 浏览器:Chrome
- 抓包工具:Charles / Fiddler / mitmproxy
- 脚本环境:
- Node.js 18+
- Python 3.10+
- 常用库:
- Node:
crypto-js - Python:
requests,hashlib,hmac,pycryptodome
- Node:
安装示例:
npm install crypto-js
pip install requests pycryptodome
核心原理
要复现前端签名,核心不是“硬猜算法”,而是搞清楚这四个问题:
- 输入是什么
- 处理顺序是什么
- 算法是什么
- 输出放到哪里
一个典型签名流程大致如下:
flowchart TD
A[抓到接口请求] --> B[定位可疑参数 sign/token]
B --> C[全局搜索参数名或请求URL]
C --> D[在发送前断点]
D --> E[回溯调用链]
E --> F[识别参与签名的原始字段]
F --> G[确认排序/拼接/编码规则]
G --> H[识别摘要或加密算法]
H --> I[本地脚本复现]
I --> J[对比浏览器结果]
把它拆开理解,会更清晰。
1. 输入层:哪些字段真正参与了签名
常见输入包括:
- Query 参数
- POST body
- Header 中的某些字段
- Cookie / localStorage / sessionStorage 中的 token
- 时间戳、nonce、traceId
- 路径本身,如
/api/order/list - 固定盐值或版本号
这里一个很常见的坑是:你看到的请求参数,不等于签名时的原始输入。
例如浏览器最终发的是:
{
"page": 1,
"size": 20,
"sign": "abc123"
}
但签名实际输入可能是:
path=/api/data/list&page=1&size=20&ts=1710000000000&secret=xxxx
也可能是:
JSON.stringify({page:1,size:20}) + token + ts
2. 过程层:排序、拼接、编码
这一步决定了“同样字段,为何结果不一样”。
常见处理方式:
- 按 key 字典序排序
- 过滤空值、
null、undefined - 数组按特定格式展开
- URL 编码后再签名
- JSON 压缩后签名
- 值统一转字符串
- 转小写或大写
- 前后拼接固定盐值
例如:
a=1&b=2&c=3 + secret
和
secret + a=1&b=2&c=3
哪怕算法一样,结果也完全不同。
3. 算法层:哈希、HMAC、对称加密、非对称加密
最常见的不是“纯加密”,而是签名摘要:
- MD5
- SHA1 / SHA256
- HMAC-SHA256
- 自定义变种:先 Base64 再 MD5,或先 AES 再 MD5
也有些接口会把业务参数整体加密:
- AES-CBC / AES-ECB
- DES / 3DES
- RSA 分段加密
- SM2 / SM4(国产算法场景较多)
经验上看:
- 只有一个短字符串参数
sign:多半是哈希/HMAC - 一个长字符串参数
data/payload:多半是 AES/RSA 加密结果 - 既有
data又有sign:通常是“先加密业务参数,再对密文或明文签名”
4. 输出层:签名放在哪
常见位置:
- Query:
?sign=xxx - Body:
{"sign":"xxx"} - Header:
X-Sign: xxx - Cookie:较少,但有
你必须确认签名字段是最终产物,还是中间产物。有些站点会先算一个 token,再基于 token 算第二个 sign。
一套稳定的实战分析路径
我建议按下面的顺序做,不要跳。
sequenceDiagram
participant U as 分析者
participant B as 浏览器页面
participant J as 前端JS
participant S as 服务端接口
U->>B: 打开页面并触发请求
B->>J: 组装请求参数
J->>J: 生成ts/nonce/sign/data
J->>S: 发起XHR/Fetch请求
S-->>J: 返回结果
J-->>B: 渲染页面
U->>J: 在请求发送前断点
U->>J: 回溯sign/data生成逻辑
U->>U: 本地脚本复现算法
U->>S: 脚本请求验证
背景与问题:一个可复现的小案例
为了让过程具体,我们构造一个典型接口:
- 请求地址:
POST /api/demo/list - 请求体:
{
"page": 1,
"size": 20,
"ts": 1710000000000,
"nonce": "8f3a2c1d",
"sign": "待计算"
}
假设前端签名规则是:
- 取
page、size、ts、nonce - 按 key 升序排序
- 拼成
key=value形式,用&连接 - 在尾部拼接
&secret=demo_key_2025 - 对最终字符串做
SHA256 - 结果转小写十六进制,作为
sign
待签名原串类似这样:
nonce=8f3a2c1d&page=1&size=20&ts=1710000000000&secret=demo_key_2025
这类结构在真实项目里非常常见:不复杂,但细节很多,稍微错一处就会失败。
核心原理:如何在前端定位签名逻辑
方法一:从 Network 面板反推
看到请求里有 sign 后,先做这几步:
- 在 Network 中找到目标请求
- 记下:
- URL 路径
- Method
- Query / Payload
- Header 中可能相关的字段
- 到 Sources 全局搜索:
- 请求路径关键字,如
/api/demo/list - 参数名,如
sign - 常见发送函数,如
fetch、axios.post
- 请求路径关键字,如
如果代码没混淆太狠,通常能直接搜到。
方法二:XHR / Fetch 断点
如果搜索不到,就用更稳的方式:
- Sources → Event Listener Breakpoints
- 勾选:
XHR/fetch Breakpoints- 或添加 URL 关键字断点
当请求发出前停住时,看调用栈(Call Stack),不断往上回退,直到找到:
- 参数组装函数
- 签名函数
- 加密函数
这是我最常用的方法,因为它不依赖变量名是否可读。
方法三:Hook 关键 API
如果目标站点代码混淆重、调用链长,可以临时 Hook:
window.fetchXMLHttpRequest.prototype.sendJSON.stringifyCryptoJS.SHA256btoa/atobencrypt/decrypt之类的全局函数
例如在控制台注入:
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch args]', args);
debugger;
return rawFetch.apply(this, args);
};
如果站点用了 CryptoJS.SHA256,甚至可以直接拦:
const rawSHA256 = CryptoJS.SHA256;
CryptoJS.SHA256 = function (...args) {
console.log('[SHA256 input]', args[0]);
debugger;
return rawSHA256.apply(this, args);
};
这样你能直接看到签名前的原始字符串。
实战代码(可运行)
下面用一个完整示例演示:前端签名函数 + Python/Node 复现。
1. 前端示例签名代码
function buildSign(params, secret) {
const sortedKeys = Object.keys(params).sort();
const pairs = [];
for (const key of sortedKeys) {
const value = params[key];
if (value === undefined || value === null || value === '') continue;
pairs.push(`${key}=${String(value)}`);
}
const plain = `${pairs.join('&')}&secret=${secret}`;
return CryptoJS.SHA256(plain).toString(CryptoJS.enc.Hex);
}
// 示例
const params = {
page: 1,
size: 20,
ts: 1710000000000,
nonce: '8f3a2c1d'
};
const sign = buildSign(params, 'demo_key_2025');
console.log(sign);
2. Node.js 复现
如果你已经确认算法是 SHA256,那么 Node.js 原生 crypto 就够用了。
const crypto = require('crypto');
function buildSign(params, secret) {
const plain = Object.keys(params)
.sort()
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== '')
.map((key) => `${key}=${String(params[key])}`)
.join('&') + `&secret=${secret}`;
return crypto.createHash('sha256').update(plain, 'utf8').digest('hex');
}
const params = {
page: 1,
size: 20,
ts: 1710000000000,
nonce: '8f3a2c1d'
};
const sign = buildSign(params, 'demo_key_2025');
console.log('sign =', sign);
运行:
node sign.js
3. Python 复现
import hashlib
def build_sign(params: dict, secret: str) -> str:
items = []
for key in sorted(params.keys()):
value = params[key]
if value is None or value == '':
continue
items.append(f"{key}={value}")
plain = "&".join(items) + f"&secret={secret}"
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
params = {
"page": 1,
"size": 20,
"ts": 1710000000000,
"nonce": "8f3a2c1d"
}
sign = build_sign(params, "demo_key_2025")
print("sign =", sign)
4. Python 发起真实请求示例
import time
import uuid
import hashlib
import requests
def build_sign(params: dict, secret: str) -> str:
items = []
for key in sorted(params.keys()):
value = params[key]
if value is None or value == '':
continue
items.append(f"{key}={value}")
plain = "&".join(items) + f"&secret={secret}"
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
def make_nonce() -> str:
return uuid.uuid4().hex[:8]
url = "https://example.com/api/demo/list"
secret = "demo_key_2025"
payload = {
"page": 1,
"size": 20,
"ts": int(time.time() * 1000),
"nonce": make_nonce()
}
payload["sign"] = build_sign(payload, secret)
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
}
resp = requests.post(url, json=payload, headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
进一步升级:AES 加密 + 签名的组合场景
真实站点里,常见的是这个结构:
- 业务 JSON 先 AES 加密成
data - 再对
data + ts + nonce + secret做签名 - 请求体发送:
{
"data": "密文",
"ts": 1710000000000,
"nonce": "8f3a2c1d",
"sign": "摘要"
}
这种时候你要分两段分析:
- 加密链:明文 JSON 如何变成
data - 签名链:哪些字段参与签名
下面给一个可运行的 Node 示例。
Node.js:AES-CBC + SHA256
const crypto = require('crypto');
function aesEncrypt(plainText, key, iv) {
const cipher = crypto.createCipheriv(
'aes-128-cbc',
Buffer.from(key, 'utf8'),
Buffer.from(iv, 'utf8')
);
let encrypted = cipher.update(plainText, 'utf8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
}
function buildSign(data, ts, nonce, secret) {
const plain = `data=${data}&nonce=${nonce}&ts=${ts}&secret=${secret}`;
return crypto.createHash('sha256').update(plain, 'utf8').digest('hex');
}
const bizObj = {
page: 1,
size: 20
};
const key = '1234567890abcdef';
const iv = 'abcdef1234567890';
const ts = 1710000000000;
const nonce = '8f3a2c1d';
const secret = 'demo_key_2025';
const data = aesEncrypt(JSON.stringify(bizObj), key, iv);
const sign = buildSign(data, ts, nonce, secret);
console.log({ data, ts, nonce, sign });
Python:AES-CBC + SHA256
import json
import base64
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
def aes_encrypt(plain_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(plain_text.encode("utf-8"), AES.block_size))
return base64.b64encode(encrypted).decode("utf-8")
def build_sign(data: str, ts: int, nonce: str, secret: str) -> str:
plain = f"data={data}&nonce={nonce}&ts={ts}&secret={secret}"
return hashlib.sha256(plain.encode("utf-8")).hexdigest()
biz_obj = {
"page": 1,
"size": 20
}
key = "1234567890abcdef"
iv = "abcdef1234567890"
ts = 1710000000000
nonce = "8f3a2c1d"
secret = "demo_key_2025"
data = aes_encrypt(json.dumps(biz_obj, separators=(",", ":")), key, iv)
sign = build_sign(data, ts, nonce, secret)
print({
"data": data,
"ts": ts,
"nonce": nonce,
"sign": sign
})
这里我特意用了:
json.dumps(biz_obj, separators=(",", ":"))
因为很多前端 JSON.stringify 输出是紧凑格式,没有空格。这个细节非常关键。
逐步验证清单
我建议你在复现时,不要一次性写完“最终脚本”,而是按下面顺序一点点核对。
第 1 步:确认请求结构
核对这些内容是否一致:
- URL
- Method
- Query
- Body
- Header
- Cookie
- Origin / Referer
第 2 步:确认动态字段来源
重点查:
ts是秒还是毫秒nonce长度和生成规则token来自 Cookie、localStorage 还是内存变量
第 3 步:确认签名前原串
这是最关键的一步。你必须拿到:
- 原始拼接字符串
- 排序规则
- 编码规则
- 是否过滤空值
第 4 步:确认算法与输出格式
核对:
- MD5 / SHA256 / HMAC-SHA256
- Hex / Base64
- 大写 / 小写
第 5 步:浏览器值与本地值对拍
做一个最小输入,把浏览器里的:
plainsign
打印出来,再和脚本结果逐项比较。
如果不一致,不要继续往后写请求脚本,先把签名对齐。
混淆代码下的定位技巧
很多时候你看到的是这种代码:
a["b"](c["d"](e, f), g())
变量名全废了。这时靠“读代码”会非常痛苦。我更推荐以下方法。
1. 盯住稳定锚点
稳定锚点包括:
- 请求 URL
- 固定 Header 名
- 参数名
sign/data CryptoJSencodeURIComponentJSON.stringify
这些东西即使被混淆,通常也不会完全消失。
2. 用调用栈,不要硬读全文件
当断点停住时,看调用栈,顺着往上点,你会很快找到:
- 谁在构造 body
- 谁在计算 sign
- 谁在做加密
这比在几十万行 bundle 里瞎搜要高效得多。
3. 直接打印中间值
如果你已经找到疑似函数,最有效的方式往往不是“理解全部逻辑”,而是先插日志:
console.log('params=', params);
console.log('plain=', plain);
console.log('sign=', sign);
我当时踩过一个坑,就是花了半小时分析某个 helper 干了什么,后来发现只要打印一下入参和出参,三分钟就搞清楚了。
常见坑与排查
这一节非常重要。实际失败大多不是“算法错得离谱”,而是细节差一位。
stateDiagram-v2
[*] --> 抓包成功
抓包成功 --> 请求重放失败
请求重放失败 --> 检查时间戳
请求重放失败 --> 检查排序规则
请求重放失败 --> 检查JSON序列化
请求重放失败 --> 检查Header参与签名
请求重放失败 --> 检查编码方式
检查时间戳 --> 成功
检查排序规则 --> 成功
检查JSON序列化 --> 成功
检查Header参与签名 --> 成功
检查编码方式 --> 成功
成功 --> [*]
坑 1:秒级时间戳和毫秒级时间戳搞混
前端常见:
Date.now() // 毫秒
Math.floor(Date.now() / 1000) // 秒
如果服务端校验窗口很严,错一个单位直接失败。
坑 2:JSON 序列化不一致
例如前端签名用的是:
JSON.stringify({a:1,b:2})
你 Python 写成:
json.dumps({"a": 1, "b": 2})
默认会带空格,字符串就不同了。
正确做法通常是:
json.dumps(obj, separators=(",", ":"), ensure_ascii=False)
坑 3:字段顺序不一致
虽然很多语言里的字典现在“看起来有序”,但你仍然不应该赌。
要明确:
- 是按插入顺序
- 还是字典序排序
- 还是固定字段顺序
坑 4:URL 编码时机错误
下面两种结果不同:
name=张三&city=北京
和
name=%E5%BC%A0%E4%B8%89&city=%E5%8C%97%E4%BA%AC
必须确认:
- 签名前编码
- 还是签名后编码
- 编码一次还是两次
坑 5:Hex/Base64 混用
例如前端:
CryptoJS.SHA256(plain).toString(CryptoJS.enc.Hex)
和
CryptoJS.SHA256(plain).toString(CryptoJS.enc.Base64)
完全不同。
坑 6:AES 模式、填充方式、密钥长度错了
AES 常见差异:
- ECB / CBC
- PKCS7 / ZeroPadding
- key 长度 16 / 24 / 32
- iv 是否参与
- 输出是 Hex 还是 Base64
只要其中一个参数错,密文就全变。
坑 7:签名依赖运行时环境
有些站点签名依赖这些浏览器特征:
navigator.userAgentwindow.screen- Canvas 指纹
- WebGL 信息
- 时区 / 语言
这类站点单纯复现算法还不够,可能还要补环境。
坑 8:Header 也参与签名
特别容易漏掉:
AuthorizationX-TimestampX-NonceX-App-Version
你看到 body 一样,不代表签名前原串一样。
排查思路:失败时怎么快速定位
如果你已经写好了脚本,但接口还是失败,我建议按下面顺序排:
1. 先比“浏览器原始串”和“脚本原始串”
不要先比最终 sign,先比签名前字符串。
如果原串不同,后面都不用看。
2. 再比摘要输出格式
确认:
- 小写 hex?
- 大写 hex?
- Base64?
- 去掉了
=padding 吗?
3. 再检查请求环境差异
包括:
- Header 是否一致
- Cookie 是否一致
- 是否缺少 token
- Origin / Referer 是否被校验
4. 最后才考虑反爬或风控
如果签名完全对了还失败,再看:
- 是否有设备指纹
- 是否有行为校验
- 是否有请求频率限制
- 是否有一次性 nonce
安全/性能最佳实践
这一节不是“站在服务端教育前端”,而是从分析和复现两边都值得知道的点来说。
1. 不要迷信前端加密的安全性
前端代码运行在用户环境中,算法、密钥、流程理论上都可能被观察到。把关键安全能力完全放在前端,本身就不稳。
更合理的做法是:
- 前端只做传输层保护或协议封装
- 服务端做真正的鉴权与验签
- 密钥分级管理,不在前端暴露长期核心密钥
2. 签名设计要防重放
如果你是协议设计方,建议至少加入:
- 时间戳
- nonce
- 过期窗口
- 服务端幂等或 nonce 去重
否则别人抓到一包就能长期重放。
3. 避免过度复杂但不可维护的自定义算法
很多项目喜欢自己设计一套“魔改签名”,结果是:
- 前端维护困难
- 多端实现不一致
- 一升级就兼容性出问题
- 实际安全收益并不高
优先考虑成熟方案:
- HMAC-SHA256
- AES-GCM / AES-CBC(配合标准填充与密钥管理)
- 标准 OAuth / JWT / AK-SK 方案
4. 复现脚本中做好缓存与连接复用
如果你需要批量请求,不要每次都:
- 重新初始化重型对象
- 重新建 TCP 连接
- 重复计算可缓存的静态内容
Python 中建议使用 requests.Session():
import requests
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0"
})
这样性能和稳定性都会更好。
5. 调试期保留中间日志,生产期减少敏感输出
调试时建议记录:
- 原始参数
- 签名前字符串
- 签名结果
- 请求响应
但进入正式环境后,应避免把这些敏感内容直接落日志,尤其是:
- token
- secret
- 明文业务数据
- 加密前原串
一个更贴近真实项目的分析模板
下面给你一个我自己常用的逆向记录模板。真到项目里,照着填会很省事。
接口基础信息
- 接口路径:
- 请求方法:
- Content-Type:
- 是否需要登录:
关键字段
- 动态参数:
- 签名字段:
- 密文字段:
- Header 特殊字段:
参与签名的内容
- 是否包含 path:
- 是否包含 query:
- 是否包含 body:
- 是否包含 header:
- 是否包含 token:
预处理规则
- 排序方式:
- 空值过滤:
- URL 编码:
- JSON 序列化方式:
- 拼接格式:
算法信息
- 哈希算法:
- 加密算法:
- 模式/填充:
- 输出格式:
验证结果
- 浏览器原串:
- 本地原串:
- 浏览器 sign:
- 本地 sign:
- 是否一致:
这个模板的价值在于:你会强迫自己把模糊判断变成确定信息。
总结
从前端加密参数定位到接口签名算法复现,本质上不是“猜谜”,而是一个可拆解、可验证、可复盘的过程:
- 先抓请求,识别关键参数
- 再在发送前断点,回溯调用链
- 拿到签名前原始输入
- 确认排序、拼接、编码规则
- 识别摘要/加密算法与输出格式
- 用 Node 或 Python 本地复现
- 逐项对拍浏览器结果,直到完全一致
如果你只记住一句话,我建议记这个:
签名复现的关键,不是先认出算法,而是先拿到“签名前原串”。
因为一旦原串对了,算法通常很快就能确认;但如果原串错了,哪怕你猜对了 SHA256、AES,也还是会失败。
最后给几个可执行建议:
- 优先用断点和调用栈,不要一上来就啃混淆代码
- 先打印中间值,再谈理解全局逻辑
- 每次只验证一个变量:原串、摘要、请求结构,分层排查
- 对 Python/Node 复现,重点盯
JSON.stringify差异、排序规则、编码方式 - 如果签名已对仍失败,再去考虑风控、指纹、环境依赖
只要你把这套方法走熟,遇到大多数“前端加密 + 接口签名”场景,基本都能拆开解决。