Web逆向实战:中级开发者如何定位并复现前端请求签名算法
很多中级开发者第一次接触 Web 逆向时,最容易卡住的不是“代码看不懂”,而是“知道它在签名,但不知道从哪一层下手”。页面里混着打包代码、混淆函数、动态参数、时间戳、环境检测,抓到请求之后看着 sign、token、v、t 这些字段,脑子里只有一个问题:它到底怎么算出来的?
这篇文章我不讲“神秘技巧”,而是按一套更稳定的实战路径,带你做一遍:
- 先定位签名生成点
- 再判断签名依赖哪些输入
- 最后在本地复现可运行版本
文章面向中级开发者,默认你会用浏览器开发者工具,会看一点 JavaScript,也知道基础哈希算法是什么。
说明:本文讨论的是学习前端安全机制、接口调试、自动化测试兼容性分析等合法场景。请在授权范围内操作,避免触碰业务和法律边界。
背景与问题
前端请求签名的目标,本质上是让服务端判断:这个请求是不是按预期客户端生成的。常见用途包括:
- 防止接口被随意构造
- 增加爬虫和脚本调用门槛
- 校验参数是否被篡改
- 配合时间戳、nonce 避免重放
一个典型请求可能长这样:
POST /api/order/list
Content-Type: application/json
{
"page": 1,
"pageSize": 20,
"timestamp": 1719999999999,
"nonce": "ab12cd34",
"sign": "7b2d2d8e6d7f..."
}
问题在于,sign 通常不是单纯 md5(body) 这么简单,它可能结合:
- 请求路径
- 请求方法
- 请求体排序结果
- 时间戳
- nonce
- 固定盐值
- 用户 token
- 浏览器环境指纹
而且这些逻辑往往分散在多个文件里,甚至经过 webpack 打包、代码压缩、变量混淆。
所以真正的难点不是“会不会写 MD5”,而是:
- 去哪找签名入口?
- 怎么确认参与签名的原始字符串?
- 怎样在脱离浏览器页面后复现?
前置知识
在开始之前,建议你至少熟悉以下内容:
- Chrome DevTools 的
Network、Sources、Console - JavaScript 基础语法
- 常见哈希算法:MD5 / SHA1 / SHA256 / HMAC
- JSON 序列化与对象排序
- Node.js 基本运行方式
如果你对混淆代码有点陌生,也没关系。实战里更多依赖的是定位能力,不是一次性读懂全部源码。
环境准备
推荐准备下面这些工具:
- Chrome 浏览器
- Node.js 18+
- VS Code
- 一个抓包/调试环境
- 可选:
mitmproxy、Fiddler、Charles
本地新建一个目录:
mkdir sign-reverse-demo
cd sign-reverse-demo
npm init -y
npm install crypto-js axios
核心原理
前端请求签名,通常可以抽象成这样一条流水线:
flowchart LR
A[收集输入参数] --> B[参数标准化]
B --> C[按规则拼接原文]
C --> D[哈希/加密处理]
D --> E[附加到请求中]
E --> F[服务端校验]
这里最关键的是中间三步:
-
参数标准化
比如 key 排序、去除空值、统一布尔值和数字格式 -
原文拼接
比如method + path + timestamp + body + salt -
哈希/加密处理
比如md5(raw)、sha256(raw)、hmac_sha256(raw, secret)
很多人逆向失败,不是因为算法太难,而是因为拼接前的标准化规则没还原对。
常见签名输入模型
下面是实战中最常见的一类:
sign = md5(
method.toUpperCase()
+ "|" + path
+ "|" + timestamp
+ "|" + nonce
+ "|" + canonical_json(body)
+ "|" + secret
)
还有一类是 HMAC:
sign = hmac_sha256(canonical_query + "\n" + canonical_body, secret)
还有更复杂的版本,会引入:
- token 派生密钥
- 浏览器指纹参与签名
- 服务端下发动态盐值
- wasm 实现哈希过程
- native bridge / worker 内计算
一张图看懂定位思路
我平时更推荐“从请求反推代码”,而不是“从源码盲猜请求”。
sequenceDiagram
participant U as 用户操作
participant B as 浏览器页面
participant S as 签名函数
participant N as 网络请求
participant API as 服务端
U->>B: 点击按钮/触发接口
B->>S: 组装参数并计算 sign
S-->>B: 返回 sign
B->>N: 发送带 sign 的请求
N->>API: 请求到达服务端
API-->>N: 校验 sign 并返回结果
这张图的启发很直接:
签名一定发生在“发送请求之前”。所以你不需要先理解整个项目,只需要先抓到“请求发送前的那一段”。
背景与问题:到底从哪开始找?
真实项目里,我一般按这个顺序排查:
1. 先在 Network 面板看请求
重点关注:
- 请求 URL
- Query 参数
- Request Payload
- Headers
- 发起时机
你要先确认:签名字段到底在哪?
可能在:
- Query:
?sign=xxx&t=xxx - Body:
{"sign":"xxx"} - Header:
X-Sign: xxx
2. 用全局搜索找字段名
在 Sources 里全局搜这些关键词:
signsignaturetokennoncetimestampsha256md5CryptoJSsortstringify
如果字段名被混淆了,也不要慌。可以换思路搜:
- 请求 URL 片段,比如
/api/order/list - axios/fetch 调用点
- 请求拦截器,如
axios.interceptors.request.use
3. 卡在发送前打断点
这是实战里最有效的一步。
如果项目用 axios,可以先在请求拦截器附近打断点;
如果用 fetch/XHR,可以在 DevTools 的 XHR/fetch Breakpoints 里对 URL 关键字断住。
断住之后看三件事:
- 当前请求对象里有哪些字段
sign是现成的还是刚算出来的- 调用栈里谁生成了它
实战案例:复现一个常见签名算法
下面我们模拟一个很典型的场景。假设页面请求逻辑如下:
- 请求方法:
POST - 路径:
/api/order/list - 请求体:业务 JSON
- 时间戳:毫秒级
- nonce:随机字符串
- 签名算法:对规范化后的字符串做
MD5
签名规则
raw = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + CANONICAL_BODY + "\n" + SECRET
sign = md5(raw)
其中 CANONICAL_BODY 规则为:
- 对象 key 按字典序排序
- 递归处理嵌套对象
- 数组保持原顺序
- 去掉
undefined - 最终输出 JSON 字符串
逐步验证清单
建议你每次逆向都按这个清单来,不容易漏:
- 确认 sign 在 query、body 还是 header
- 确认算法是 hash 还是加密
- 确认是否有时间戳和 nonce
- 确认 body 是否排序
- 确认 path 是否参与签名
- 确认 method 是否大写
- 确认是否用了固定盐值或动态密钥
- 确认 JSON.stringify 前是否做了预处理
- 确认浏览器环境值是否参与
- 用同一组输入在页面和本地比对结果
实战代码(可运行)
下面给出一个可直接运行的 Node.js 示例。它做两件事:
- 生成与前端一致的签名
- 用 axios 发起请求
1)签名复现代码
新建 sign.js:
const CryptoJS = require('crypto-js');
function sortObject(value) {
if (Array.isArray(value)) {
return value.map(sortObject);
}
if (value && typeof value === 'object') {
const sorted = {};
Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort()
.forEach((key) => {
sorted[key] = sortObject(value[key]);
});
return sorted;
}
return value;
}
function canonicalize(obj) {
return JSON.stringify(sortObject(obj));
}
function md5(text) {
return CryptoJS.MD5(text).toString(CryptoJS.enc.Hex);
}
function generateNonce(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
function generateSign({ method, path, timestamp, nonce, body, secret }) {
const canonicalBody = canonicalize(body);
const raw = [
method.toUpperCase(),
path,
String(timestamp),
nonce,
canonicalBody,
secret,
].join('\n');
return {
raw,
sign: md5(raw),
};
}
module.exports = {
canonicalize,
generateNonce,
generateSign,
};
2)请求调用代码
新建 request.js:
const axios = require('axios');
const { generateNonce, generateSign } = require('./sign');
async function main() {
const method = 'POST';
const path = '/api/order/list';
const timestamp = Date.now();
const nonce = generateNonce(8);
const secret = 'demo_secret_2026';
const body = {
page: 1,
pageSize: 20,
filters: {
status: 'paid',
keyword: 'book',
},
items: [3, 2, 1],
};
const { raw, sign } = generateSign({
method,
path,
timestamp,
nonce,
body,
secret,
});
console.log('签名原文:\n', raw);
console.log('sign:', sign);
try {
const response = await axios({
url: `https://example.com${path}`,
method,
data: {
...body,
timestamp,
nonce,
sign,
},
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
console.log('响应状态:', response.status);
console.log('响应数据:', response.data);
} catch (error) {
if (error.response) {
console.error('请求失败:', error.response.status, error.response.data);
} else {
console.error('请求异常:', error.message);
}
}
}
main();
运行:
node request.js
如何在真实页面里定位这个算法
上面的代码只是“复现结果”,但真正关键的是:你怎么找到这些规则的?
下面给你一个更接近真实工作的定位路径。
方法一:从 axios/fetch 入口逆推
如果页面用了 axios,请优先看这几个点:
axios.interceptors.request.use((config) => {
// 很多签名逻辑在这里
return config;
});
你要观察:
config.urlconfig.methodconfig.paramsconfig.dataconfig.headers
然后找有没有类似:
config.headers['X-Sign'] = makeSign(config);
或者:
config.data.sign = signPayload(config.data);
方法二:直接 Hook 哈希函数
如果你怀疑用了 CryptoJS.MD5 或 CryptoJS.SHA256,可以在控制台临时 hook。
Hook MD5
(function () {
if (!window.CryptoJS || !CryptoJS.MD5) {
console.log('CryptoJS.MD5 不存在');
return;
}
const original = CryptoJS.MD5;
CryptoJS.MD5 = function (...args) {
console.log('[HOOK MD5] input:', args[0]);
const result = original.apply(this, args);
console.log('[HOOK MD5] output:', result.toString());
debugger;
return result;
};
console.log('MD5 hook 已安装');
})();
这样做的好处是:
你不一定要先读懂所有业务代码,只要页面一旦签名,就会把签名前原文暴露出来。
我自己踩过一个坑:一开始盯着混淆函数反复看,结果看了半小时都没看明白。后来直接 hook
SHA256,一分钟就抓到了原文拼接格式。
方法三:Hook JSON.stringify
有些项目在 JSON.stringify 前会先做排序,所以你看到的 body 可能和最终签名原文不一样。
这时可以 hook 一下:
(function () {
const original = JSON.stringify;
JSON.stringify = function (...args) {
const result = original.apply(this, args);
console.log('[HOOK stringify] input:', args[0]);
console.log('[HOOK stringify] output:', result);
return result;
};
console.log('JSON.stringify hook 已安装');
})();
方法四:查看调用栈而不是死盯变量名
现在很多项目变量名都被压缩成 a, b, c。
这时不要执着于“函数名是什么”,而要看:
- 调用栈
- 哪一层接收了请求参数
- 哪一层把 sign 塞回请求对象
复杂场景下的判断分支
并不是所有签名都能“一眼看出来”。下面是一个判断图,帮你快速缩小范围。
flowchart TD
A[发现请求含 sign] --> B{能搜到 sign 关键词吗}
B -->|能| C[定位赋值位置]
B -->|不能| D[从请求拦截器/XHR断点入手]
C --> E{调用了 CryptoJS / SubtleCrypto / wasm 吗}
D --> E
E -->|CryptoJS| F[Hook MD5/SHA/HMAC]
E -->|SubtleCrypto| G[Hook crypto.subtle.digest/sign]
E -->|wasm| H[关注 wasm 导出函数与内存读写]
E -->|都没有| I[检查自定义算法与字符变换]
F --> J[提取原文与参数规则]
G --> J
H --> J
I --> J
J --> K[本地最小复现]
K --> L[与页面结果逐项比对]
常见坑与排查
这一部分非常重要。很多“算法复现失败”,最后都不是算法本身错了,而是细节没对齐。
1. JSON 排序规则不一致
最常见问题之一。
比如页面里实际处理的是:
- 先删除空字段
- 再递归排序
- 再 stringify
而你本地只是直接 JSON.stringify(body),结果当然不一样。
排查建议:
- 打印页面签名前原文
- 打印本地签名前原文
- 一行一行对比,不要只比最终 sign
2. 时间戳单位错了
可能是:
- 秒:
1719999999 - 毫秒:
1719999999999
只差三个零,签名就全错。
3. 路径参与签名,但你用了完整 URL
有些页面签的是:
/api/order/list
不是:
https://example.com/api/order/list
这一点很容易漏。
4. 请求体字段顺序变了
有些后端会严格按前端排序规则验签。
如果你在本地重新构造对象时字段顺序不同,虽然 JSON 语义一样,但签名结果会不同。
5. HMAC 和普通哈希搞混
这两者很像,但完全不是一回事。
错误示例:
sha256(raw + secret)
正确示例可能是:
hmac_sha256(raw, secret)
6. 编码问题
常见差异包括:
- UTF-8
- Base64
- Hex
- URL 编码前后顺序
- Unicode 转义
尤其是中文、空格、特殊字符,很容易把结果搞偏。
7. 动态盐值没拿到
有些 secret 不是写死在前端,而是:
- 登录后服务端下发
- 页面初始化接口返回
- 保存在 localStorage / sessionStorage / cookie
- 由 token 二次派生
这时你如果只看静态 JS 文件,会误判“算法不完整”。
8. 签名算法在 WebAssembly 里
如果你看到页面加载了 .wasm,要留意:
- wasm 导出函数名
- JS 如何把参数写入内存
- 返回值是 hex、base64 还是二进制
这时不要一上来就反编译 wasm,先看 JS 包装层,很多关键信息其实都在那一层。
一个简单的排查脚本:对比原文
当你本地算不对时,最有效的是写个“差异对比脚本”。
新建 diff-raw.js:
function diffStrings(a, b) {
const maxLen = Math.max(a.length, b.length);
for (let i = 0; i < maxLen; i++) {
if (a[i] !== b[i]) {
console.log('首次差异位置:', i);
console.log('a 字符:', JSON.stringify(a[i]));
console.log('b 字符:', JSON.stringify(b[i]));
console.log('a 前后片段:', JSON.stringify(a.slice(Math.max(0, i - 20), i + 20)));
console.log('b 前后片段:', JSON.stringify(b.slice(Math.max(0, i - 20), i + 20)));
return;
}
}
console.log('两个字符串完全一致');
}
const pageRaw = `POST
/api/order/list
1719999999999
ab12cd34
{"filters":{"keyword":"book","status":"paid"},"items":[3,2,1],"page":1,"pageSize":20}
demo_secret_2026`;
const localRaw = `POST
/api/order/list
1719999999999
ab12cd34
{"page":1,"pageSize":20,"filters":{"status":"paid","keyword":"book"},"items":[3,2,1]}
demo_secret_2026`;
diffStrings(pageRaw, localRaw);
运行:
node diff-raw.js
这个小工具很朴素,但在实战里真的很好用。
很多时候你不是“算法不会”,而是“原文只差一个字符”。
安全/性能最佳实践
这里分两部分说:一部分给逆向分析者,一部分给前端/后端开发者。
站在分析与调试角度
1. 先复现最小闭环,不要一开始追求完整工程化
建议先做到:
- 固定输入
- 固定时间戳
- 固定 nonce
- 算出和页面一致的 sign
只要闭环跑通,再去封装自动化脚本。
我见过不少人一上来就写整套并发采集程序,结果基础签名都还没对齐,浪费很多时间。
2. 记录“原文”和“结果”两层日志
最少保留:
- 原始参与签名字符串
- 最终 sign
- 时间戳
- nonce
- 请求路径
这样一旦失败,能快速回放。
3. 把环境依赖剥离出来
比如:
- localStorage 中的 token
- cookie 中的 session
- 页面初始化接口返回的动态密钥
最好显式注入,而不是写死在代码里。
站在系统防护角度
如果你是业务开发者,也要知道:前端签名只能提高门槛,不能单独作为安全边界。
1. 不要把“静态前端密钥”当成真正秘密
只要密钥在前端可达,理论上就能被提取。
所以更合理的做法是:
- 使用短期动态密钥
- 与用户会话、设备信息、时间窗口绑定
- 服务端做频控、风控、行为校验
2. 服务端校验要覆盖完整上下文
不要只验一个 sign 字段,还要校验:
- 时间窗口
- nonce 是否重复
- token 是否有效
- 参数是否越权
- 来源行为是否异常
3. 性能上避免过重签名流程
如果每个请求都做特别重的前端加密,可能导致:
- 首屏卡顿
- 移动端耗电
- 低端设备掉帧
比较稳妥的实践是:
- 高频接口用轻量签名
- 敏感接口再叠加强校验
- 复杂算法放服务端,不把全部逻辑前移
一个更贴近真实项目的建议流程
如果你要在工作里稳定处理这类问题,我建议固定成下面这套 SOP:
第一步:抓包确认请求形态
记录:
- URL
- Method
- Headers
- Query
- Body
- 签名字段位置
第二步:找发送入口
优先找:
- axios request interceptor
- fetch 包装函数
- XHR send 前逻辑
第三步:锁定签名前原文
通过:
- hook hash 函数
- 断点
- console 注入
- 调用栈追踪
第四步:最小复现
先不要管登录态、复杂代理、批量调度。
只要能在 Node 里把 sign 算出来,就已经成功了 80%。
第五步:联调验证
逐项核对:
- 时间戳
- nonce
- body 排序
- 编码
- 路径
- method
- secret 来源
总结
前端请求签名逆向,难点从来不只是“某个加密算法”,而是算法上下文的完整还原。
你真正需要掌握的是一套稳定的定位方法:
- 先看请求里签名出现在哪
- 再从请求发送前的代码断住
- 优先抓“签名前原文”而不是直接猜算法
- 本地先做最小复现,再逐项补环境依赖
- 一旦失败,先比原文,不要只比最终 sign
如果你已经是中级开发者,这件事的门槛其实不在数学,而在耐心和路径。
别一上来就被混淆代码吓住——多数场景里,真正有价值的信息就藏在请求发出前那几步。
最后给你几个可执行建议:
- 第一次做时,优先挑 axios + CryptoJS 的目标练手
- 任何复现都先固定
timestamp和nonce - 一定要保存“页面原文”和“本地原文”
- 如果静态分析很痛苦,优先 hook 哈希函数
- 遇到 wasm 或动态密钥时,不要急着全盘反编译,先看 JS 包装层
只要你能稳定把“原文拼接规则”抓出来,后面的算法复现通常就只是时间问题。