从抓包到还原签名链路:中级开发者实战分析 Web 逆向中的前端加密与接口鉴权机制
本文聚焦合法授权的安全研究、接口联调、自家系统排障与测试环境分析。不要将文中方法用于未授权目标。
很多中级开发者第一次碰 Web 逆向,卡住的点并不是“不会写代码”,而是不知道该从哪里下手。
表面上看,只是一个前端发请求:
- URL 也看到了
- 参数也能抄下来
- Cookie 也复制了
但你用 Postman 或脚本一发,服务端却回你:
sign invalidtimestamp expiredunauthorizedrisk control blocked
问题往往不在“接口不存在”,而在于你只抄到了结果,没有还原出签名链路。
这篇文章我会按一个更接近真实工作的路径来讲:先抓包,再定位加密点,再还原签名输入,最后用可运行代码复现请求。重点不是某个站点的私有算法,而是你以后遇到类似页面时,能自己拆出来。
背景与问题
在现代 Web 应用里,常见的接口保护手段大致有这几类:
- 静态 Token / Cookie 鉴权
- 登录态、Session、JWT
- 动态签名
- 如
sign = md5(path + body + ts + secret)
- 如
- 请求体加密
- 如 AES 加密 payload,服务端解密后再处理
- 设备指纹 / 风控参数
- 浏览器环境、Canvas、WebGL、时区、语言等
- 时序校验
- 时间戳、nonce、防重放
很多人一上来就搜“JS 逆向”,然后直接在压缩后的大 bundle 里找 sign。这不算错,但效率不高。更稳的方法,是先建立链路图:
- 哪个请求失败
- 失败的是哪个字段
- 字段来自哪里
- 它依赖哪些输入
- 输入又从什么上下文拿
一个典型现象
你抓到如下请求:
POST /api/order/list HTTP/1.1
Host: example.com
Content-Type: application/json
X-Timestamp: 1699999999
X-Nonce: p8Qx2kLm
X-Sign: 8a4c7b5d...
Cookie: sessionid=abc123
{"page":1,"size":20}
你照着发:
- Header 一样
- Body 一样
- Cookie 一样
结果服务端还是报签名错误。
这时要意识到:签名不是一个静态值,而是基于当前请求上下文实时计算的。通常至少依赖:
- 请求路径
- 请求方法
- 规范化后的参数
- 时间戳
- nonce
- 某个前端内置或下发的 secret / token
前置知识
如果你已经熟悉下面内容,阅读会更顺:
- 浏览器开发者工具 Network / Sources / Application
- 基本抓包工具使用
- JavaScript 基础
- 常见哈希与对称加密概念:
- MD5 / SHA256
- AES-CBC / AES-GCM
- Base64 / Hex / UTF-8
- HTTP 请求结构与 Cookie / Header 作用
环境准备
建议准备以下工具:
- Chrome DevTools
- 抓包工具
- Charles / Fiddler / mitmproxy 任选其一
- Node.js 16+
- Python 3.9+
- 可选:
webpackbundle 美化插件source-map-explorer- AST 工具如
@babel/parser
我个人常用的组合是:
- 浏览器里先看 Network 和 Sources
- 需要改包时用 mitmproxy
- 需要快速复算签名时用 Node.js
- 需要批量联调时用 Python
背景与问题:为什么“抄请求”经常失败
先用一张图把整体流程摆出来。
flowchart TD
A[浏览器页面操作] --> B[前端收集输入]
B --> C[生成 ts/nonce]
C --> D[参数规范化]
D --> E[签名或加密]
E --> F[组装 Header/Body]
F --> G[发送接口请求]
G --> H[服务端验签/解密]
H --> I{通过?}
I -- 是 --> J[返回业务数据]
I -- 否 --> K[返回鉴权/风控错误]
你在抓包工具里看到的,通常只是 F -> G 这一步。
但服务端校验看的却是整条链:
ts是否过期nonce是否重复- body 是否被改动
- 参数顺序是否一致
- sign 是否匹配
- cookie / token 是否与用户态对应
所以真正要还原的,不是“某个 sign 字符串”,而是sign 的生成方法。
核心原理
这一部分我们不讲站点私货,而讲你在实际项目里最常见的几种模式。
1. 鉴权参数通常分布在哪
前端接口鉴权参数常见出现位置:
- Header
X-SignX-TimestampAuthorization
- QueryString
?sign=...&t=...
- Body
{ data: "...密文...", sign: "..." }
- Cookie
csrf_tokensessionid
它们之间往往存在依赖关系。
2. 常见签名链路模式
模式 A:纯签名,不加密
sign = SHA256(method + path + sortedParams + ts + nonce + secret)
特点:
- 参数可见
- 重点在签名校验
- 服务端更容易排查
模式 B:先加密,再签名
cipher = AES(payload, key, iv)
sign = MD5(path + cipher + ts + secret)
特点:
- 抓包看到的是密文
- 需要先定位加密函数
- 密文编码方式常见为 Base64 / Hex
模式 C:服务端下发动态密钥
page load -> get config -> receive token/seed -> derive sign key -> request api
特点:
- 仅看单个接口不够
- 要补抓初始化配置接口
- 常有 token 轮换、过期时间
3. 参数规范化是高频坑点
即使你算法抄对了,参数拼接规则错一点也会失败。
比如同一个对象:
{"b":2,"a":1}
不同实现下可能变成:
b=2&a=1a=1&b=2{"b":2,"a":1}{"a":1,"b":2}
服务端要求的往往是固定规范,比如:
- 对象 key 按字典序排序
- 忽略值为
null或undefined的字段 - 数组用
,拼接或保留 JSON 字面量 - Unicode 编码先转 UTF-8
- 空格是否编码成
%20而不是+
这个地方我踩过很多次坑:不是算法错,是输入串不一致。
4. 浏览器端加密代码藏在哪
一般可以优先从这几处找:
- Network 里失败请求对应的 Initiator
- Sources 全局搜索关键字:
signsha256md5encrypttimestampnonceauthorization
- 在请求发出前下断点:
XMLHttpRequest.prototype.sendfetch
- 搜索固定错误提示文案
- 搜索请求头名称,比如
X-Sign
下面这张时序图更直观:
sequenceDiagram
participant U as 用户操作
participant P as 页面脚本
participant S as 签名模块
participant B as 浏览器请求层
participant R as 服务端
U->>P: 点击查询
P->>S: 传入 path/body/上下文
S->>S: 生成 ts/nonce
S->>S: 规范化参数
S->>S: 哈希/加密
S-->>P: 返回 sign/header/body
P->>B: fetch/XHR 发请求
B->>R: 发送请求
R->>R: 验签/验时效/验会话
R-->>B: 返回结果
B-->>P: Promise resolve/reject
一套实战分析方法:从抓包到还原
下面给一个可迁移的方法论,你可以套到自己的授权测试环境中。
第一步:先锁定“最小可复现请求”
不要一开始就分析最复杂的接口。
优先找:
- 请求参数少
- 返回稳定
- 不依赖复杂页面状态
- 失败时有明确报错
例如“获取列表第一页”,通常比“提交订单”更适合入手。
第二步:确认哪些字段是动态的
抓两次同样操作,对比请求差异:
- body 是否变了
ts是否变了nonce是否变了sign是否变了- 有无新的 token
如果只有 ts/nonce/sign 变化,说明这是典型签名请求。
第三步:验证 sign 是否依赖 body
可以在浏览器里改一个无关参数,比如 page=1 改成 page=2,看 sign 是否变化。
- 变化:说明 body/query 参与签名
- 不变化:可能只校验路径、时间戳和 token
第四步:定位发请求前的最后一跳
在浏览器控制台中 hook fetch 或 XHR,打印参数。
Hook fetch
const rawFetch = window.fetch;
window.fetch = async function (...args) {
console.log('[fetch args]', args);
const res = await rawFetch.apply(this, args);
return res;
};
Hook XHR
(function () {
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._method = method;
this._url = url;
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
console.log('[xhr]', this._method, this._url, body);
return send.apply(this, arguments);
};
})();
这一步的意义是:先看到请求发送前的原始输入,再往上找是谁算出的 sign。
第五步:逆着调用栈回溯签名函数
在 fetch 或 XHR 处打断点,查看调用栈:
- 哪个模块传入了 headers
- 哪个函数组装了
X-Sign - 哪个函数生成了
timestamp
通常你会看到类似:
headers["X-Sign"] = u(n, t, e)
这里别急着看压缩变量名,先做三件事:
- 在调用前打印入参
- 在函数返回处打印结果
- 确认它是否纯函数
如果是纯函数,基本就能脱离页面单独复现。
第六步:还原“签名原文”
这是最关键的一步。
假设你在代码里发现这样的逻辑:
const plain = `${method}\n${path}\n${sortedQuery}\n${bodyStr}\n${ts}\n${nonce}`;
const sign = sha256(plain + secret);
那么你真正要复现的是:
sortedQuery如何排序bodyStr如何序列化secret从哪来ts/nonce格式是什么
不是简单抄 sha256。
实战代码(可运行)
下面我构造一个教学用最小案例:前端对请求体做规范化,再生成签名。你可以直接运行,体会“链路复现”的关键点。
场景说明
假设页面发送请求时采用下面规则:
- 请求方法:
POST - 路径:
/api/order/list - body 为 JSON
ts为秒级时间戳nonce为随机字符串sign = SHA256(method + "\n" + path + "\n" + canonicalBody + "\n" + ts + "\n" + nonce + "\n" + secret)
Node.js 版签名还原
// sign-demo.js
const crypto = require('crypto');
function sortObject(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map(sortObject);
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
const value = obj[key];
if (value !== undefined) {
acc[key] = sortObject(value);
}
return acc;
}, {});
}
function canonicalJSONStringify(obj) {
return JSON.stringify(sortObject(obj));
}
function sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function createNonce(len = 8) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let s = '';
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function buildSign({ method, path, body, ts, nonce, secret }) {
const canonicalBody = canonicalJSONStringify(body);
const plain = [
method.toUpperCase(),
path,
canonicalBody,
String(ts),
nonce,
secret
].join('\n');
const sign = sha256(plain);
return {
canonicalBody,
plain,
sign
};
}
function main() {
const method = 'POST';
const path = '/api/order/list';
const body = {
size: 20,
page: 1,
filter: {
status: 'paid',
tags: ['vip', 'new']
}
};
const ts = Math.floor(Date.now() / 1000);
const nonce = createNonce(8);
const secret = 'demo_secret_123456';
const result = buildSign({ method, path, body, ts, nonce, secret });
console.log('canonicalBody =', result.canonicalBody);
console.log('plain =\n' + result.plain);
console.log('sign =', result.sign);
// 模拟最终请求头
const headers = {
'Content-Type': 'application/json',
'X-Timestamp': String(ts),
'X-Nonce': nonce,
'X-Sign': result.sign
};
console.log('headers =', headers);
}
main();
运行方式:
node sign-demo.js
Python 版复现
如果你更习惯用 Python 联调接口,可以写成这样:
# sign_demo.py
import json
import time
import random
import string
import hashlib
def sort_object(obj):
if obj is None or not isinstance(obj, (dict, list)):
return obj
if isinstance(obj, list):
return [sort_object(item) for item in obj]
return {
key: sort_object(value)
for key, value in sorted(obj.items(), key=lambda x: x[0])
if value is not None
}
def canonical_json_dumps(obj):
return json.dumps(sort_object(obj), ensure_ascii=False, separators=(",", ":"))
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def create_nonce(length=8):
chars = string.ascii_letters + string.digits
return "".join(random.choice(chars) for _ in range(length))
def build_sign(method, path, body, ts, nonce, secret):
canonical_body = canonical_json_dumps(body)
plain = "\n".join([
method.upper(),
path,
canonical_body,
str(ts),
nonce,
secret
])
sign = sha256(plain)
return canonical_body, plain, sign
def main():
method = "POST"
path = "/api/order/list"
body = {
"size": 20,
"page": 1,
"filter": {
"status": "paid",
"tags": ["vip", "new"]
}
}
ts = int(time.time())
nonce = create_nonce(8)
secret = "demo_secret_123456"
canonical_body, plain, sign = build_sign(method, path, body, ts, nonce, secret)
print("canonical_body =", canonical_body)
print("plain =\n" + plain)
print("sign =", sign)
headers = {
"Content-Type": "application/json",
"X-Timestamp": str(ts),
"X-Nonce": nonce,
"X-Sign": sign
}
print("headers =", headers)
if __name__ == "__main__":
main()
运行方式:
python sign_demo.py
如果接口还有 AES 加密怎么办
很多页面不是直接签 JSON,而是先加密 body。下面给一个 Node.js 的教学示例。
// aes-sign-demo.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 sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function main() {
const path = '/api/secure/data';
const body = JSON.stringify({ page: 1, size: 20 });
const ts = String(Math.floor(Date.now() / 1000));
const key = '1234567890abcdef';
const iv = 'abcdef1234567890';
const secret = 'demo_secret';
const cipherText = aesEncrypt(body, key, iv);
const signPlain = [path, cipherText, ts, secret].join('|');
const sign = sha256(signPlain);
console.log('cipherText =', cipherText);
console.log('signPlain =', signPlain);
console.log('sign =', sign);
}
main();
这段代码的意义不在于算法本身,而在于提醒你:密文本身也可能参与签名。所以你不能只把解密做出来,还得还原“先后顺序”和编码格式。
逐步验证清单
我建议你每次都按这个清单走,不要凭感觉改。
验证 1:时间戳是否可用
- 服务端允许误差是几秒或几分钟
- 秒级还是毫秒级
- 是否要求 UTC
验证 2:nonce 是否有格式要求
- 固定长度?
- 只能字母数字?
- 是否必须唯一?
验证 3:参数序列化是否一致
- JSON 是否排序
- 是否压缩空格
- 中文是否
ensure_ascii=false - 布尔值大小写是否一致
验证 4:签名输入是否完整
- method 是否参与
- path 是否带域名
- query 是否参与
- body 是否参与
- secret 是固定还是动态下发
验证 5:编码方式是否正确
- Hex 还是 Base64
- UTF-8 还是 Latin1
- URL 编码发生在签名前还是签名后
验证 6:会话态是否绑定
- sign 虽然对了,但 Cookie 不对也会失败
- 某些 token 与登录用户、设备指纹绑定
常见坑与排查
这是实战里最容易浪费时间的部分。
坑 1:你以为是“加密错了”,其实是“序列化错了”
最典型的现象:
- 你本地算出的 sign 长度对
- 算法也对
- 就是跟浏览器里的 sign 不一致
优先检查:
- 对象 key 顺序
- 数组顺序
- 空字段是否参与
- JSON 是否带空格
- 数字和字符串类型是否被隐式转换
例如:
JSON.stringify({a:1,b:2}) !== '{"b":2,"a":1}'
如果服务端按固定字典序验签,而你直接 JSON.stringify 原始对象,就会翻车。
坑 2:你看到的 secret 并不是最终 secret
很多项目会把 secret 再做一层处理,比如:
- base64 decode
- 字符串切片
- 与时间戳拼接
- 通过 wasm 导出
- 从配置接口获取 seed 后派生
所以不要只搜“看起来像密钥的字符串”,而要看最终传进 hash/encrypt 的值。
坑 3:Hook 晚了,错过原始值
有些站点在页面初始化阶段就缓存了原始 fetch 或签名函数引用。你后面再 hook,可能已经来不及。
排查建议:
- 刷新前就在 Snippets 注入 hook
- 使用断点而不是只靠
console.log - 必要时在本地代理层改包观察
坑 4:不是前端签名,而是服务端下发一次性票据
有些请求里的 sign 根本不是前端算的,而是服务端预先下发的:
- 页面初始化接口返回一个 ticket
- 提交接口直接带这个 ticket
- ticket 绑定时间窗和用户态
这时你再怎么搜 sha256 都没用。要回到抓包链路,确认票据来源。
坑 5:浏览器环境参与了计算
常见于风控场景:
navigator.userAgent- 屏幕分辨率
- 时区
- Canvas 指纹
- WebGL 信息
如果脚本复现总失败,而浏览器里总成功,就要怀疑环境差异。
一个实用排查流程图
flowchart TD
A[请求复现失败] --> B{错误类型}
B -->|sign invalid| C[检查签名原文]
B -->|timestamp expired| D[检查时间戳单位/时区]
B -->|unauthorized| E[检查 Cookie/Token]
B -->|risk blocked| F[检查环境指纹/频率限制]
C --> C1[参数排序]
C --> C2[编码格式]
C --> C3[secret 来源]
C --> C4[是否漏了 path/method/query]
D --> D1[秒/毫秒]
D --> D2[本地时钟漂移]
E --> E1[登录态是否过期]
E --> E2[token 是否和用户绑定]
F --> F1[UA/Referer]
F --> F2[浏览器指纹]
F --> F3[请求频率]
安全/性能最佳实践
这一节从“开发者应该怎么设计”和“分析时怎么避免误判”两个角度说。
1. 不要把前端加密当成真正的密钥保护
从安全设计角度讲:
- 任何放在前端的 secret,都默认可被提取
- 前端签名更适合做:
- 防止参数被随意篡改
- 增加滥用门槛
- 配合时效和会话做基础风控
- 不适合做:
- 高价值密钥长期保存
- 单独依赖前端算法作为安全边界
如果是你自己设计系统,真正敏感的校验应尽量放到服务端。
2. 签名要绑定上下文
如果只对 body 做签名,而不带上:
- path
- method
- ts
- nonce
- user/session
那么签名容易被复用或重放。
比较稳的做法是至少包含:
- 请求方法
- 路径
- 规范化参数
- 时间戳
- nonce
- 会话上下文标识
3. 服务端要做重放防护
常见做法:
- 限制
ts有效窗口,比如 300 秒 - 存储短期
nonce,拒绝重复提交 - 签名与用户会话绑定
- 对高风险接口增加验证码或二次确认
4. 前端实现要兼顾性能
很多项目把签名逻辑写在每个请求拦截器里,如果:
- 每次都做重排序
- 大对象频繁
JSON.stringify - AES 对大 payload 重复加密
会带来不小的性能开销。
优化思路:
- 只对必要字段签名
- 避免对超大对象重复深拷贝
- 对稳定配置做缓存
- 在高频接口中减少不必要的加密层
5. 调试时保留“原文日志”
如果你在维护自家系统,我强烈建议服务端在测试环境保留可审计日志:
- 参与验签的 canonical string
- 签名失败原因
- 时间戳差值
- nonce 冲突情况
这比只返回一句 sign invalid 好排查太多。
一个更贴近真实项目的分析思路
很多中级开发者已经会抓包了,但还缺少“怎么组织证据”的意识。我的建议是,把每次逆向分析都整理成下面这张表:
| 项目 | 结论 |
|---|---|
| 请求路径 | /api/order/list |
| 请求方法 | POST |
| 鉴权字段位置 | Header |
| 动态字段 | X-Timestamp, X-Nonce, X-Sign |
| sign 是否依赖 body | 是 |
| body 序列化规则 | JSON 字典序,无空格 |
| secret 来源 | 页面配置 / 初始化接口 / 内置常量 |
| 是否依赖 Cookie | 是 |
| 是否依赖环境指纹 | 待确认 |
| 失败报错 | sign invalid |
这张表看起来朴素,但非常有用。因为你会发现,真正难的不是写出哈希代码,而是把链路中的不确定项逐个消掉。
边界条件:什么时候不值得继续深挖
不是所有接口都值得完整还原。下面几种情况,我通常会建议先停一下,评估成本:
- 强依赖复杂浏览器指纹
- 说明重点不只是签名,而是风控系统
- 关键逻辑在 wasm 或 native 容器
- 成本明显升高
- 密钥由服务端一次一发,且强绑定会话
- 适合联调,不适合脱离原上下文复现
- 接口本身有合法开放方式
- 优先走官方 SDK 或正式联调方案
逆向分析是手段,不是目的。对工程来说,最低成本拿到可验证结论更重要。
总结
把这篇文章压缩成一句话,就是:
Web 逆向里最关键的,不是“找到加密算法”,而是“还原完整签名链路”。
你可以把实战过程记成 6 步:
- 抓包:锁定最小可复现请求
- 对比:找出动态字段
- Hook:截获发送前原始参数
- 回溯:定位签名函数调用链
- 还原:确认 canonical string、secret、编码规则
- 复现:用 Node/Python 独立计算并验证
如果你现在手上就有一个“明明抓到了请求但就是复现不了”的接口,我建议立刻做这三件事:
- 抓两次完全相同的操作,做请求 diff
- 在
fetch/ XHR 发送前下断点,看 headers 谁写进去的 - 把“签名输入原文”打印出来,而不是只盯着 sign 结果
当你能稳定回答下面这几个问题时,签名链路基本就算拿下了:
- sign 的输入到底是什么?
- 输入的序列化规则是什么?
- secret 从哪里来?
- 时间戳和 nonce 的校验边界是什么?
- 会话态和环境信息是否参与?
做到这一步,你面对大多数常规前端签名与接口鉴权场景,就不会再是“靠运气试参数”了,而是能有方法地拆、有证据地改、有把握地复现。