Web逆向实战:中级开发者如何定位并复现前端签名算法与接口加密流程
前端签名和接口加密,很多人第一反应是“这不就是加个 md5 吗”。但真到项目里,往往不是这么简单:参数排序、时间戳、随机数、环境校验、JS 混淆、甚至 WebAssembly 都可能混在一起。
这篇文章我想用一条中级开发者可落地的路径,带你从 0 到 1 做一遍:
- 抓包定位关键请求
- 找出前端签名生成逻辑
- 复现接口加密流程
- 写出可运行脚本完成验证
重点不是“背某个网站的算法”,而是形成一套稳定的方法论。
说明:本文讨论的是合法授权场景下的接口分析、联调、自动化测试和安全研究。请勿用于未授权目标。
背景与问题
在前后端分离项目里,接口往往会增加一层“门槛”:
- 请求参数里多一个
sign - 请求头里带
X-Sign、X-Timestamp、X-Nonce - 请求体经过 AES 加密
- 响应体返回密文,需要前端解密
- 签名逻辑依赖浏览器环境,如
window.navigator、canvas指纹等
中级开发者常见卡点通常有这几个:
- 知道接口地址,但不知道 sign 怎么来的
- 能看到混淆后的 JS,但找不到核心函数
- 能算出 sign,但服务端仍返回验签失败
- 本地脚本复现成功一次,换个请求就失效
- 抓到了密文,却不知道加密前的明文结构
我自己早期踩过一个很典型的坑:以为签名算法就是 md5(timestamp + data),结果折腾半天都不对。最后发现真正参与签名的是排序后的扁平化参数串,而且空字符串字段会被过滤掉。也就是说,差一个“参数标准化”步骤,后面全错。
所以,逆向这类逻辑时,不能上来就盯着加密函数,要先建立全链路视角。
前置知识与环境准备
如果你已经会用浏览器开发者工具,可以直接跳过这节。
建议准备
- Chrome / Edge DevTools
- 抓包工具:Charles、Fiddler、mitmproxy 任选一种
- Node.js 16+
- 一份 JS 美化工具
- 可选:AST 工具,如
@babel/parser - 可选:Python 3.10+,用于快速写验证脚本
你至少需要搞清楚的概念
- 签名:通常是为了防篡改、防重放、校验请求完整性
- 加密:通常是为了隐藏请求内容,常见是 AES
- 摘要算法:MD5 / SHA1 / SHA256 等
- 参数标准化:排序、过滤空值、拼接规则、编码方式
- 浏览器环境依赖:有些函数离开
window就跑不起来
背景与问题:为什么要先看“链路”,不要先看“算法”
很多人一上来就在 Sources 面板全局搜 md5、sha256、CryptoJS。这当然有时能奏效,但效率并不稳定。更稳的方式是先把请求生命周期拆开:
flowchart TD
A[页面触发操作] --> B[组装业务参数]
B --> C[参数标准化]
C --> D[生成 timestamp/nonce]
D --> E[计算 sign]
E --> F[可选: 加密请求体]
F --> G[发起 HTTP 请求]
G --> H[服务端验签/解密]
H --> I[返回密文或明文]
I --> J[前端解密/验签]
你真正要定位的,不是某个“神秘函数”,而是这条链路中的几个关键节点:
- 原始业务参数是什么
- 签名前参数长什么样
- 时间戳和随机数格式是什么
- 密钥是写死、派生,还是服务端下发
- 最终 HTTP 报文里哪些字段参与了签名
核心原理
这一节不讲过度抽象,我直接按实战里最常见的模式拆。
1. 常见签名结构
很多前端签名其实都能归到这个公式:
sign = HASH( canonical(params) + secret + timestamp + nonce )
其中:
HASH:MD5 / SHA1 / SHA256 / HMAC-SHA256canonical(params):参数排序并拼接后的字符串secret:可能硬编码、动态下发、或由 token 派生timestamp:毫秒/秒级时间戳nonce:随机串,防重放
2. 常见加密结构
请求体加密通常有两层:
- 签名层:防篡改
- 加密层:隐藏内容
例如:
plaintext body -> AES-CBC encrypt -> ciphertext
sign = SHA256(ciphertext + timestamp + nonce + secret)
也可能是:
sign = HMACSHA256(JSON.stringify(data), secret)
body = RSA(publicKey, AESKey) + AES(data, AESKey)
3. 你真正要复现的是“协议”,不是单个函数
这点非常重要。
很多人看到某个函数:
function s(e){return md5(e)}
就以为它是核心。其实它只是最后一步。真正关键的是 e 是怎么来的:
- 对象有没有排序
- 数组是否展开
undefined是否忽略- 布尔值是否转成字符串
- 中文是否 URL encode
- JSON 是否压缩掉空格
- key 名字有没有大小写转换
这些差异,都会让最终 sign 完全不同。
定位路径:从抓包到断点的实战方法
我建议你按这个顺序走,成功率最高。
第一步:抓包确认“异常字段”
先在 Network 面板看目标请求,重点观察:
- Query String Parameters
- Request Payload
- Headers
- Cookie
- Response
优先找这些特征字段:
sign/signaturets/timestampnoncetokendata/encryptData- 看起来像 Base64 或 Hex 的长字符串
如果一个请求里同时出现:
- 一个时间戳
- 一个随机串
- 一个 32 或 64 位十六进制串
那基本就是签名流程了。
第二步:发起点打断点
找到请求触发位置,通常可以在以下位置下手:
fetchXMLHttpRequest.prototype.send- axios 拦截器
- 某个 API 封装文件
你可以先在 DevTools 里搜:
fetch(axios.createinterceptors.request.useXMLHttpRequestCryptoJSmd5sha256
第三步:Hook 通用 API,看入参
如果代码混淆得比较厉害,别急着啃业务代码。先 Hook 常见加密函数。
例如在控制台注入:
(function () {
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch args]', args);
return rawFetch.apply(this, args);
};
})();
如果页面用的是 CryptoJS,还可以 Hook:
(function () {
if (!window.CryptoJS) return;
const rawStringify = CryptoJS.enc.Utf8.stringify;
CryptoJS.enc.Utf8.stringify = function (...args) {
console.log('[Utf8.stringify]', args);
return rawStringify.apply(this, args);
};
})();
当然,更常用的是直接 Hook 签名前的字符串生成函数,这需要你结合调用栈去找。
第四步:对比两次请求,抽出变量
这是非常实用的一招。
同一个接口发两次,只改一个业务参数,比如把:
{"page":1,"size":10}
改成:
{"page":2,"size":10}
然后对比:
- 哪些字段跟着变
- sign 是否完全变化
- ciphertext 长度是否变化
- nonce 是否总是不同
- timestamp 是否参与校验窗口
通过差分分析,你能快速判断:
- sign 依赖哪些参数
- 加密是对整体 body 还是部分字段
- 时间戳是否只是辅助字段
实战案例:复现一个典型前端签名 + AES 请求流程
下面我们构造一个常见但足够真实的例子来演示。目标流程如下:
- 前端业务参数对象
data - 参数排序并拼接
- 生成
timestamp和nonce - 用
SHA256生成sign - 用
AES-CBC加密请求体 - 组装请求发送
协议假设
- 签名串格式:
appId=demo&page=1&size=10×tamp=1562391621&nonce=abc123&secret=top_secret
sign = sha256(签名串)body = AES-CBC(JSON字符串, key, iv),输出 Base64- 请求格式:
{
"appId": "demo",
"timestamp": 1562391621,
"nonce": "abc123",
"sign": "xxx",
"data": "base64cipher"
}
实战代码(可运行)
下面分别给出 Node.js 版本和 Python 版本,方便你交叉验证。
Node.js 版本
先安装依赖:
npm install crypto-js axios
1)签名与加密实现
const CryptoJS = require('crypto-js');
const APP_ID = 'demo';
const SECRET = 'top_secret';
const AES_KEY = CryptoJS.enc.Utf8.parse('1234567890abcdef');
const AES_IV = CryptoJS.enc.Utf8.parse('abcdef1234567890');
function generateNonce(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < length; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
function canonicalize(obj) {
return Object.keys(obj)
.filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
.sort()
.map((k) => `${k}=${String(obj[k])}`)
.join('&');
}
function createSign(data, timestamp, nonce) {
const base = {
appId: APP_ID,
...data,
timestamp,
nonce,
};
const signStr = `${canonicalize(base)}&secret=${SECRET}`;
const sign = CryptoJS.SHA256(signStr).toString(CryptoJS.enc.Hex);
return { sign, signStr };
}
function encryptData(data) {
const plaintext = JSON.stringify(data);
const encrypted = CryptoJS.AES.encrypt(plaintext, AES_KEY, {
iv: AES_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}
function buildRequestPayload(data) {
const timestamp = Math.floor(Date.now() / 1000);
const nonce = generateNonce();
const { sign, signStr } = createSign(data, timestamp, nonce);
const encryptedData = encryptData(data);
return {
payload: {
appId: APP_ID,
timestamp,
nonce,
sign,
data: encryptedData,
},
debug: {
signStr,
plaintext: JSON.stringify(data),
},
};
}
// demo
const data = {
page: 1,
size: 10,
keyword: 'phone',
};
const result = buildRequestPayload(data);
console.log(JSON.stringify(result, null, 2));
2)带请求发送的示例
const axios = require('axios');
const CryptoJS = require('crypto-js');
const APP_ID = 'demo';
const SECRET = 'top_secret';
const AES_KEY = CryptoJS.enc.Utf8.parse('1234567890abcdef');
const AES_IV = CryptoJS.enc.Utf8.parse('abcdef1234567890');
function generateNonce(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let out = '';
for (let i = 0; i < length; i++) {
out += chars[Math.floor(Math.random() * chars.length)];
}
return out;
}
function canonicalize(obj) {
return Object.keys(obj)
.filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
.sort()
.map((k) => `${k}=${String(obj[k])}`)
.join('&');
}
function createSign(data, timestamp, nonce) {
const signBase = {
appId: APP_ID,
...data,
timestamp,
nonce,
};
const signStr = `${canonicalize(signBase)}&secret=${SECRET}`;
return CryptoJS.SHA256(signStr).toString(CryptoJS.enc.Hex);
}
function encryptData(data) {
return CryptoJS.AES.encrypt(JSON.stringify(data), AES_KEY, {
iv: AES_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
}).toString();
}
async function requestDemo() {
const data = { page: 1, size: 10 };
const timestamp = Math.floor(Date.now() / 1000);
const nonce = generateNonce();
const sign = createSign(data, timestamp, nonce);
const encryptedData = encryptData(data);
const payload = {
appId: APP_ID,
timestamp,
nonce,
sign,
data: encryptedData,
};
try {
const resp = await axios.post('https://example.com/api/list', payload, {
headers: {
'Content-Type': 'application/json',
},
timeout: 5000,
});
console.log(resp.data);
} catch (err) {
console.error('request failed:', err.message);
}
}
requestDemo();
Python 版本
先安装依赖:
pip install pycryptodome requests
1)签名与 AES-CBC 加密
import json
import time
import random
import string
import hashlib
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
APP_ID = "demo"
SECRET = "top_secret"
AES_KEY = b"1234567890abcdef"
AES_IV = b"abcdef1234567890"
def generate_nonce(length=8):
chars = string.ascii_lowercase + string.digits
return ''.join(random.choice(chars) for _ in range(length))
def canonicalize(obj):
items = []
for k in sorted(obj.keys()):
v = obj[k]
if v is None or v == "":
continue
items.append(f"{k}={v}")
return "&".join(items)
def create_sign(data, timestamp, nonce):
sign_base = {
"appId": APP_ID,
**data,
"timestamp": timestamp,
"nonce": nonce
}
sign_str = f"{canonicalize(sign_base)}&secret={SECRET}"
sign = hashlib.sha256(sign_str.encode("utf-8")).hexdigest()
return sign, sign_str
def encrypt_data(data):
plaintext = json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
return base64.b64encode(ciphertext).decode("utf-8")
def build_payload(data):
timestamp = int(time.time())
nonce = generate_nonce()
sign, sign_str = create_sign(data, timestamp, nonce)
encrypted = encrypt_data(data)
return {
"payload": {
"appId": APP_ID,
"timestamp": timestamp,
"nonce": nonce,
"sign": sign,
"data": encrypted
},
"debug": {
"signStr": sign_str,
"plaintext": json.dumps(data, ensure_ascii=False, separators=(",", ":"))
}
}
if __name__ == "__main__":
data = {"page": 1, "size": 10, "keyword": "phone"}
result = build_payload(data)
print(json.dumps(result, ensure_ascii=False, indent=2))
如何把浏览器里的真实算法“抠”出来
上面的例子是标准流程。但在真实项目里,你拿到的是混淆后的代码。下面是更贴近实战的定位思路。
方法一:从请求拦截器逆推
很多项目都会在 axios 请求拦截器里统一签名:
axios.interceptors.request.use((config) => {
const ts = Date.now();
const nonce = genNonce();
const sign = makeSign(config.data, ts, nonce);
config.headers['X-Timestamp'] = ts;
config.headers['X-Nonce'] = nonce;
config.headers['X-Sign'] = sign;
return config;
});
所以你搜 interceptors.request.use,往往能直接摸到签名函数入口。
方法二:在加密库调用处下断点
如果页面里出现:
CryptoJS.AES.encryptCryptoJS.SHA256window.btoaatobSubtleCryptoTextEncoder
就可以在这些调用处下断点,查看上层调用栈。
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名函数
participant E as 加密函数
participant H as HTTP客户端
participant R as 服务端
U->>P: 点击搜索/翻页
P->>S: 传入业务参数
S-->>P: 返回 sign/timestamp/nonce
P->>E: 传入明文 data
E-->>P: 返回密文 data
P->>H: 组装 headers/body
H->>R: 发起请求
R-->>H: 返回密文/明文
H-->>P: 响应处理
方法三:直接 Hook 摘要函数,打印输入
如果能找到 md5 或 sha256 的实现对象,直接包一层最省时间:
(function () {
if (!window.CryptoJS || !CryptoJS.SHA256) return;
const raw = CryptoJS.SHA256;
CryptoJS.SHA256 = function (...args) {
console.log('[SHA256 input]', args[0]);
const out = raw.apply(this, args);
console.log('[SHA256 output]', out.toString());
return out;
};
})();
这样做有一个好处:你不用先完全读懂混淆代码,就能先看到签名前的原始字符串。
我个人在调试复杂站点时,经常先这么干。因为“输入串”一旦拿到,后面大多数时候就是参数还原问题了。
逐步验证清单
中级开发者最容易犯的错误不是“不会写代码”,而是一次改太多,不知道哪一步错了。建议按下面清单逐项验证。
验证 1:参数标准化是否一致
确认这些问题:
- 是否按 key 字典序排序
- 是否过滤空值
- 是否把数字转字符串
- 数组是
a=1,2还是a[0]=1&a[1]=2 - 对象是否做 JSON 序列化
- 中文是否编码
验证 2:时间戳单位是否一致
常见有三种:
- 秒:
1562391621 - 毫秒:
1562391621123 - 字符串时间:
2019-07-06 05:40:21
只差 3 位,签名就全错。
验证 3:哈希输出格式是否一致
同一个 SHA256,输出可能是:
- Hex 小写
- Hex 大写
- Base64
- 截取前 16 位
例如:
CryptoJS.SHA256(str).toString(CryptoJS.enc.Hex)
和
CryptoJS.SHA256(str).toString()
通常是一样的 Hex,但有些库默认不一样,不能想当然。
验证 4:AES 模式/填充是否正确
重点确认:
- ECB / CBC / GCM
- IV 是否固定
- 输出是 Base64 还是 Hex
- 密钥是原始字符串还是
Utf8.parse后的 WordArray - 明文 JSON 是否有空格
验证 5:请求头和 Cookie 是否也参与签名
很多验签失败,并不是算法错,而是漏掉了:
AuthorizationX-Device-IdUser-Agent- 某个 Cookie 值
- 服务端下发的动态 token
常见坑与排查
这部分我尽量写得“接地气”一点,因为这里最容易浪费时间。
坑 1:只复现了 sign,没有复现“参数预处理”
现象:
- 你算出的 sign 看起来格式完全对
- 但服务端始终返回
invalid sign
排查方式:
- 打印浏览器里签名前的完整字符串
- 打印你本地脚本签名前的完整字符串
- 一字符一字符比对
最常见差异:
- 少了某个字段
- 排序不一致
- JSON 空格不同
- 布尔值
true被转成True null被当成字符串"null"
坑 2:AES 能加密,但服务端解不开
现象:
- 密文格式像是对的
- 但返回“解密失败”
排查方式:
- 确认 key 长度:16/24/32 字节
- 确认 IV 长度:16 字节
- 确认模式:CBC/ECB
- 确认 padding:Pkcs7/ZeroPadding
- 确认输出编码:Base64/Hex
很多 Node 和 Python 库默认行为不完全一样,尤其是字符串转字节这一步。
坑 3:浏览器里能跑,本地脚本跑不起来
现象:
- 在浏览器 Console 调用函数成功
- 拷到 Node 里报错
window is not defined
原因通常是签名函数依赖了浏览器环境:
window.navigator.userAgentlocation.hrefdocument.cookiecanvas指纹atob/btoa
解决思路:
- 补最小运行环境 mock
- 或者抽出纯算法部分单独运行
示例:
global.window = {
navigator: { userAgent: 'Mozilla/5.0 demo' },
location: { href: 'https://example.com' },
};
global.document = {
cookie: 'token=abc123',
};
坑 4:签名偶尔成功,偶尔失败
这类问题我见过很多,通常不是算法错,而是时间窗口或随机因子复用问题。
排查重点:
- 服务端是否校验 5 秒/30 秒时间窗口
- nonce 是否要求唯一
- 是否有一次性 token
- 请求重放是否被拦截
坑 5:JS 混淆太深,根本读不动
这时候不要硬啃所有代码,优先做三件事:
- 美化代码
- 断在请求发起点
- 用调用栈向上找 2~4 层
很多情况下,真正关键的逻辑并不多,盯住“请求前最后一步”就够了。
安全/性能最佳实践
这部分不仅对逆向分析有帮助,对你自己设计接口协议也很有价值。
安全方面
1)不要把“前端签名”当成真正的安全边界
前端代码天然可见,算法和密钥只要在浏览器执行,就有被分析的可能。前端签名更适合:
- 增加调用门槛
- 降低随意抓接口的成本
- 防止简单篡改和重放
但不适合单独承担核心安全能力。
2)敏感密钥不要硬编码在前端
如果把长期有效的 secret 直接写进 JS 包,逆向者迟早能拿到。更合理的思路是:
- 使用短期 token
- 服务端参与派生密钥
- 用设备态、会话态信息做辅助校验
- 服务端做频控、行为风控
3)签名要覆盖关键字段
至少应覆盖:
- 路径
- 业务参数
- 时间戳
- nonce
- 用户态标识
否则只签了一部分字段,篡改空间还很大。
性能方面
1)避免重复加密大对象
如果请求体很大,每次都全量 JSON 序列化 + AES,移动端性能会比较差。可以考虑:
- 只加密敏感字段
- 减少深层对象结构
- 避免重复签名同一数据
2)统一签名中间层
工程上建议把这部分收敛到一层,而不是散落到各个页面:
classDiagram
class ApiClient {
+request(config)
}
class Signer {
+canonicalize(data)
+createSign(data, ts, nonce)
}
class Encryptor {
+encrypt(data)
+decrypt(data)
}
ApiClient --> Signer
ApiClient --> Encryptor
这样做的好处是:
- 更容易排查问题
- 更容易做协议升级
- 更容易插入日志和灰度控制
3)保留调试开关,但不要泄露到生产日志
很多项目为了排查问题,会打印:
- 签名前字符串
- AES 明文
- token/nonce/sign
开发环境可以,生产环境一定要谨慎,否则日志系统本身就成了泄露点。
一套我常用的定位模板
如果你想把这篇文章的方法直接带走,我建议记住下面这套顺序:
A. 先抓包确认字段
看有没有:
- sign
- timestamp
- nonce
- encryptData
B. 再找请求入口
优先找:
- axios request interceptor
- fetch 封装
- XHR send 调用点
C. 再 Hook 常见函数
优先 Hook:
- SHA256 / MD5
- AES.encrypt
- JSON.stringify
- btoa / atob
D. 抓“签名前原串”
这是最关键证据。
E. 最后复现协议
顺序通常是:
- 参数标准化
- 时间戳/nonce
- sign
- body 加密
- 请求头补齐
别反过来。很多人上来先复现 AES,最后才发现 sign 串都错了。
总结
前端签名算法和接口加密流程,看起来花哨,拆开以后其实核心就几步:
- 找到请求触发点
- 抓出签名前原始字符串
- 搞清参数标准化规则
- 确认摘要算法、输出格式、时间戳单位
- 确认 AES 模式、IV、编码
- 用本地脚本逐步复现并做差分验证
如果你是中级开发者,我给你的可执行建议是:
- 不要先读全量混淆代码,先抓链路
- 不要先猜算法,先拿签名前原串
- 不要一步到位,先把 sign 跑通,再补加密
- 每次只验证一个变量:排序、时间戳、编码、IV
最后再强调边界:前端逆向更像是协议还原,不是单纯“解密函数搜索”。一旦你把“参数标准化 + 签名输入 + 加密输入”这三个中间态抓住,问题通常就已经解决了大半。
如果你照这套流程做,下一次再遇到 sign、nonce、AES 混在一起的接口,不会再只剩“硬猜算法”这一条路。