从请求签名到参数还原:一次中级 Web 逆向实战中的加密逻辑定位与复现
很多人学 Web 逆向,卡住的不是“不会下断点”,而是知道请求里有签名,却不知道从哪里开始还原。
这篇文章我不讲太玄的理论,而是按一次比较典型的中级实战路径,带你从:
- 发现异常参数
- 定位签名生成点
- 还原参数处理流程
- 脱离浏览器复现请求
一步一步走完。
重点不是某个站点的特定实现,而是一套可迁移的方法论。你下次碰到 sign、token、cipher、v、_t 这类字段时,可以直接套这套思路。
背景与问题
先描述一个常见场景。
某个接口请求长这样:
POST /api/data/list HTTP/1.1
Content-Type: application/json
{
"page": 1,
"pageSize": 20,
"keyword": "test",
"timestamp": 1724810000000,
"sign": "9f0c3a..."
}
看起来很普通,但一旦你自己用 Python 或 Node 重放,就会出现几种典型失败:
- 返回“签名错误”
- 返回“参数非法”
- 返回“请求过期”
- 明明参数一样,但服务端结果不一致
这说明问题通常不只是“缺一个 sign”,而是下面几类之一:
- 签名原文不是你看到的请求体
- 参数在发送前被重排、过滤、编码或加密
- 签名依赖时间戳、随机串、设备指纹、环境值
- 浏览器端做了多层封装,真实逻辑藏在打包代码里
我当时踩过的一个坑就是:
表面看签名只依赖 JSON 字段,实际上它先把对象按键名排序,再把空值删掉,最后拼接一个固定盐值做 MD5。你直接对原始对象 JSON.stringify,永远对不上。
所以,Web 逆向里真正的关键不是“会不会某个算法”,而是:
搞清楚签名前,参数到底经历了什么。
前置知识与环境准备
这篇文章默认你已经会这些基础操作:
- Chrome DevTools 抓包
- Sources 面板全局搜索
- XHR / Fetch 断点
- 简单阅读压缩后的 JS
- Node.js 运行脚本
推荐环境:
- Chrome 最新版
- Node.js 18+
- 一个代码编辑器
- 可选:mitmproxy / Fiddler / Charles
如果你之前只会“看 Network 面板”,那建议这次重点练这三个能力:
- 从请求字段反推入口函数
- 从入口函数反推参数标准化逻辑
- 把浏览器内逻辑搬到独立脚本中复现
问题拆解:先别急着算 sign
中级实战里,最容易犯的错误是:
一看到 sign 就开始全局搜 md5、sha1、sha256。
这样有时能碰运气,但效率并不高。更稳的方法是先拆问题:
你需要回答的 4 个问题
- 哪些字段参与签名?
- 参与签名的字段是否做了排序、过滤、编码?
- 签名算法是什么?
- 签名时是否混入了额外上下文值?
把这个过程画成图,大概是这样:
flowchart TD
A[抓到目标请求] --> B[识别异常字段 sign/timestamp/nonce/data]
B --> C[定位请求发起代码]
C --> D[找到签名前的参数对象]
D --> E[观察参数标准化: 排序/过滤/编码]
E --> F[定位摘要算法或加密函数]
F --> G[提取盐值/密钥/环境变量]
G --> H[脱离浏览器复现]
H --> I[对比线上请求并校验]
这张图的核心意思只有一句话:
先找“签名前的数据”,再找“签名算法”。
核心原理
这一类请求签名,本质上通常由三层组成。
1. 参数标准化
服务端为了避免同一组参数因为顺序不同而签名不同,通常会做标准化,比如:
- 按键名排序
- 删除
null、undefined、空字符串 - 布尔值/数字转字符串
- 对嵌套对象做稳定序列化
- URL 编码或特殊字符转义
例如下面两个对象,语义一样,但直接 JSON.stringify 结果不一定一致:
const a = { b: 2, a: 1 };
const b = { a: 1, b: 2 };
如果服务端要求按 key 排序,那么签名原文应统一成:
a=1&b=2
2. 混入上下文
常见参与项包括:
timestampnonce- 固定盐值
appSecret - 用户 token
- UA、平台标识
- 某个运行时生成的设备 ID
典型拼接形式:
appId=1001&keyword=test&page=1&pageSize=20×tamp=1724810000000&secret=abc123
3. 摘要或加密
常见实现有:
- MD5
- SHA1 / SHA256
- HMAC-SHA256
- AES 加密后再 Base64
- 自定义字符映射、异或、位运算混淆
注意一点:
很多场景里所谓“加密参数”,其实只是:
- 先序列化
- 再做摘要签名
- 或者 Base64 包装
它并不一定真的是强加密。
一次典型定位流程
下面我用一个“教学型案例”来演示完整路径。
假设我们看到请求:
{
"page": 1,
"pageSize": 20,
"keyword": "laptop",
"timestamp": 1724810000000,
"nonce": "7f3a9c21",
"sign": "e4f1d6a0f8c7..."
}
第一步:在 Network 面板确认参数来源
先看请求是:
- Query String
- Form Data
- Request Payload
如果请求体里直接出现 sign,那大概率是 JS 在发请求前动态生成的。
接着看 Initiator,跳到发起脚本。
第二步:给 XHR / Fetch 下断点
在 DevTools 的 Sources -> Event Listener Breakpoints -> XHR/fetch 打开断点。
重新触发请求后,观察调用栈,重点找:
- 请求封装函数
- 参数拦截器
- 通用签名函数
这一步的目标不是立刻看懂全部代码,而是找到 sign 生成前一刻的对象。
第三步:追踪 sign 赋值点
全局搜索这些关键词:
signtimestampnoncemd5shacryptodigest
如果代码混淆严重,sign: 可能搜不到,那就直接在断点处查看局部变量,逐层往上追。
第四步:确认签名原文
这是最关键的一步。
你要找的是类似这样的中间代码:
const payload = normalize(params);
const raw = serialize(payload) + secret;
const sign = md5(raw);
其中最容易忽略的是 normalize 和 serialize。
很多人直接盯着 md5,最后复现失败,就是因为漏了这两步。
请求生命周期示意
下面这张时序图可以帮助你建立整体脑图。
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant S as 签名模块
participant N as 网络层
participant API as 服务端接口
U->>P: 点击查询
P->>P: 组装原始参数
P->>S: 传入 params
S->>S: 排序/过滤/序列化
S->>S: 拼接 timestamp/nonce/secret
S->>S: 计算 sign
S-->>P: 返回签名后的参数
P->>N: 发起请求
N->>API: POST /api/data/list
API->>API: 校验 sign 与时效
API-->>N: 返回结果
N-->>P: 解析响应
实战代码(可运行)
下面我们自己实现一份“浏览器端签名逻辑”,再用 Node.js 复现它。
这个案例覆盖了中级逆向里最常见的几步:
- 删除空值
- key 排序
- 统一序列化
- 拼接 secret
- MD5 生成 sign
1. 浏览器端逻辑原型
function normalizeParams(obj) {
const result = {};
Object.keys(obj)
.sort()
.forEach((key) => {
const val = obj[key];
if (val === undefined || val === null || val === "") return;
result[key] = typeof val === "object" && !Array.isArray(val)
? JSON.stringify(sortObject(val))
: String(val);
});
return result;
}
function sortObject(obj) {
const out = {};
Object.keys(obj)
.sort()
.forEach((k) => {
const v = obj[k];
out[k] = v && typeof v === "object" && !Array.isArray(v)
? sortObject(v)
: v;
});
return out;
}
function serialize(obj) {
return Object.keys(obj)
.sort()
.map((key) => `${key}=${obj[key]}`)
.join("&");
}
假设签名逻辑是:
sign = md5(serialize(normalizeParams(params)) + "&secret=demo_secret")
2. Node.js 复现脚本
保存为 sign_demo.js:
const crypto = require("crypto");
function sortObject(obj) {
const out = {};
Object.keys(obj)
.sort()
.forEach((k) => {
const v = obj[k];
if (v && typeof v === "object" && !Array.isArray(v)) {
out[k] = sortObject(v);
} else {
out[k] = v;
}
});
return out;
}
function normalizeParams(obj) {
const result = {};
Object.keys(obj)
.sort()
.forEach((key) => {
const val = obj[key];
if (val === undefined || val === null || val === "") return;
if (Array.isArray(val)) {
result[key] = JSON.stringify(val);
} else if (typeof val === "object") {
result[key] = JSON.stringify(sortObject(val));
} else {
result[key] = String(val);
}
});
return result;
}
function serialize(obj) {
return Object.keys(obj)
.sort()
.map((key) => `${key}=${obj[key]}`)
.join("&");
}
function md5(text) {
return crypto.createHash("md5").update(text).digest("hex");
}
function buildSignedParams(params, secret) {
const normalized = normalizeParams(params);
const raw = `${serialize(normalized)}&secret=${secret}`;
const sign = md5(raw);
return {
...params,
sign,
};
}
// 测试
const params = {
page: 1,
pageSize: 20,
keyword: "laptop",
timestamp: 1724810000000,
nonce: "7f3a9c21",
extra: {
b: 2,
a: 1,
},
emptyValue: "",
unused: null,
};
const secret = "demo_secret";
const signed = buildSignedParams(params, secret);
console.log("原始参数:", params);
console.log("签名后参数:", signed);
console.log("签名原文:", `${serialize(normalizeParams(params))}&secret=${secret}`);
运行:
node sign_demo.js
3. 如果目标站点用了 SHA256
只需要把摘要部分替换:
function sha256(text) {
return crypto.createHash("sha256").update(text).digest("hex");
}
4. 如果目标站点用了 HMAC
function hmacSha256(text, secret) {
return crypto.createHmac("sha256", secret).update(text).digest("hex");
}
5. 带请求发送的完整示例
保存为 request_demo.js:
const crypto = require("crypto");
function sortObject(obj) {
const out = {};
Object.keys(obj).sort().forEach((k) => {
const v = obj[k];
out[k] = v && typeof v === "object" && !Array.isArray(v) ? sortObject(v) : v;
});
return out;
}
function normalizeParams(obj) {
const result = {};
Object.keys(obj).sort().forEach((key) => {
const val = obj[key];
if (val === undefined || val === null || val === "") return;
if (Array.isArray(val)) {
result[key] = JSON.stringify(val);
} else if (typeof val === "object") {
result[key] = JSON.stringify(sortObject(val));
} else {
result[key] = String(val);
}
});
return result;
}
function serialize(obj) {
return Object.keys(obj)
.sort()
.map((key) => `${key}=${obj[key]}`)
.join("&");
}
function md5(text) {
return crypto.createHash("md5").update(text).digest("hex");
}
function signParams(params, secret) {
const normalized = normalizeParams(params);
const raw = `${serialize(normalized)}&secret=${secret}`;
return md5(raw);
}
async function main() {
const secret = "demo_secret";
const payload = {
page: 1,
pageSize: 20,
keyword: "laptop",
timestamp: Date.now(),
nonce: Math.random().toString(16).slice(2, 10),
};
const sign = signParams(payload, secret);
const finalBody = { ...payload, sign };
console.log("发送参数:", finalBody);
// Node 18+ 原生 fetch
const res = await fetch("https://httpbin.org/post", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(finalBody),
});
const data = await res.json();
console.log("响应:", JSON.stringify(data, null, 2));
}
main().catch(console.error);
这个脚本虽然请求的是测试站,但已经具备了完整的“签名复现”结构。
你把 normalizeParams / serialize / signParams 替换成目标逻辑即可。
如何从混淆代码里提取关键逻辑
如果站点代码经过打包、压缩、变量名混淆,可以按这条线索走:
1. 不要先反混淆全量代码
全量还原非常耗时,而且很多时候没有必要。
更实用的做法是:
- 用断点停在请求发送前
- 看当前作用域里的参数对象
- 顺着调用栈只追关键几层
2. 盯住“输入”和“输出”
签名函数不管怎么混淆,本质都是:
- 输入:一个对象 / 字符串
- 输出:一个固定长度字符串
所以你可以在可疑函数前后打日志:
console.log("before sign input:", input);
console.log("after sign output:", output);
如果不方便改源代码,也可以在 Console 临时覆写函数。
3. 识别常见摘要特征
常见长度特征:
- 32 位十六进制:大概率 MD5
- 40 位十六进制:大概率 SHA1
- 64 位十六进制:大概率 SHA256
- Base64 结尾带
=:可能做了编码或密文输出
当然这不是绝对规则,但在定位阶段很好用。
参数还原的关键:别忽略“看起来不重要”的细节
很多复现失败都出在小细节。
下面这张图总结了参数从“业务对象”到“签名原文”的变形过程:
flowchart LR
A[业务参数对象] --> B[删除空值]
B --> C[键名排序]
C --> D[嵌套对象稳定化]
D --> E[类型转字符串]
E --> F[序列化为查询串]
F --> G[拼接 secret/timestamp/nonce]
G --> H[摘要输出 sign]
典型细节清单
- 数字
1和字符串"1"是否等价? - 布尔值是
true还是"true"? - 嵌套对象是否也排序?
- 数组是否原样保留顺序?
- URL 编码是在签名前还是签名后?
- 空字符串要不要参与签名?
undefined和null是否都会被过滤?
我建议你每复现一个站点,都手写一份“签名前处理规则”。
不要觉得麻烦,这能帮你快速定位误差来源。
逐步验证清单
中级读者最需要的不是“更多知识”,而是可执行验证步骤。
下面这份清单很实用:
验证 1:只比较签名原文
先不要比最终 sign,先比“签名输入字符串”。
浏览器里拿到:
keyword=laptop&nonce=7f3a9c21&page=1&pageSize=20×tamp=1724810000000&secret=demo_secret
你本地脚本也打印一份。
只要两边一字不差,后面的摘要一般就不会错。
验证 2:固定时间戳和随机数
不要每次都用实时值。
先把:
timestampnonce
固定住,这样结果可重复,便于对比。
验证 3:先复现单参数场景
比如只保留:
{ page: 1, timestamp: 1724810000000, nonce: "abcd1234" }
简化输入后更容易确认序列化规则。
验证 4:确认编码时机
尤其是中文、空格、特殊字符。
例如:
keyword: "测试 空格"
要确认是:
- 先
encodeURIComponent再签名 - 还是先签名再编码发送
这两种结果差别很大。
常见坑与排查
这一节基本是我自己和很多同学最常踩的坑。
坑 1:看到了 MD5,就以为结束了
其实 MD5 往往只是最后一步。
真正决定成败的是前面的参数标准化。
排查方法:
- 打印签名原文
- 比较排序、过滤、字符串化结果
- 检查嵌套对象是否稳定序列化
坑 2:浏览器里是对象,请求里却是字符串
有些代码流程是:
- 原始对象
JSON.stringify- AES 加密
- Base64 输出到
data
这时请求里只有一个 data 字段,你却去签原始对象,肯定对不上。
排查方法:
- 观察发送前最终 body
- 确认签名是在加密前还是加密后
- 看服务端校验的是明文还是密文
坑 3:漏掉了请求头或 Cookie 参与签名
有些接口会把这些也混进去:
Authorizationx-device-idUser-Agent- Cookie 中的某个 token
排查方法:
- 全局搜索 header 设置位置
- 看签名函数参数是否不止一个
- 在调用栈上层找上下文来源
坑 4:时间窗口限制
你本地复现逻辑完全正确,但依然报“请求过期”。
这通常说明服务端校验了时间窗口,比如 5 分钟内有效。
排查方法:
- 同步本地时间
- 实时生成 timestamp
- 检查是否有服务端时间偏移逻辑
坑 5:Node 复现和浏览器结果不同
常见原因:
- 编码实现不同
CryptoJS和 Nodecrypto输出格式不同- Unicode 处理差异
- Base64 / Hex 大小写不一致
排查方法:
- 固定输入字符串
- 只比摘要函数输出
- 明确输入编码是 UTF-8 还是其他
坑 6:数组处理方式猜错了
数组最容易“看起来一样,实际上不一样”。
例如这三种都可能存在:
tags=["a","b"]
tags=a,b
tags[0]=a&tags[1]=b
排查方法:
- 观察数组进入签名函数前的形态
- 打印序列化后的中间结果
- 注意数组顺序通常不可随便排序
安全/性能最佳实践
这部分既适合逆向分析者,也适合理解接口设计本身。
1. 签名复现脚本要模块化
不要把所有逻辑写进一个函数里。
建议拆成:
normalizeParamsserializedigestbuildRequest
这样一旦目标站点升级,只需要替换局部逻辑。
2. 做好“中间态”日志
最有价值的不是最终 sign,而是这些日志:
- 标准化后的对象
- 序列化后的字符串
- 最终摘要输入
- 摘要输出
例如:
console.log("[normalized]", normalized);
console.log("[raw]", raw);
console.log("[sign]", sign);
这能把排错速度提高很多。
3. 对高频请求做缓存,但别缓存动态签名
如果某些参数固定,可以缓存标准化结果。
但这些字段一般不能缓存过久:
timestampnonce- 一次性 token
否则服务端很可能拒绝。
4. 避免把密钥硬编码到公开仓库
如果你做的是合法测试或内部调试,也别把分析出的密钥、盐值直接提交到仓库。
至少要用环境变量:
const SECRET = process.env.APP_SECRET || "";
5. 大批量调用时控制并发
签名计算本身通常不重,但接口风控会关注:
- 请求频率
- 相同设备指纹
- 固定节奏访问
- 重复 nonce
建议:
- 做随机抖动
- 控制并发
- 正确生成 nonce
- 失败后退避重试
6. 明确边界:仅用于授权范围内的安全研究与调试
这点必须说清楚。
本文讲的是加密逻辑定位与复现方法,适用于:
- 自家业务调试
- 安全测试
- 协议兼容验证
- 教学研究
不要超出合法授权范围使用。
一个更接近真实项目的排查套路
如果你面对的是大型前端工程,比如 React/Vue + Axios 拦截器 + Webpack 打包,我建议按这个顺序排:
路线 A:从请求发起点往前追
适合你已经知道目标接口。
- Network 找接口
- 看 Initiator
- 定位请求封装层
- 找请求拦截器
- 找 sign 注入位置
路线 B:从异常参数往前追
适合 sign 很明显。
- 全局搜索
sign - 看赋值语句
- 看传入参数
- 看标准化函数
- 回溯 secret 来源
路线 C:从加密库往回追
适合字段名被混淆。
- 搜
crypto.subtle - 搜
createHash - 搜
CryptoJS - 搜
md5(/sha256( - 反查调用者
这三条路线没有绝对优先级。
我一般会先走 A,A 不顺再补 B,最后用 C 兜底。
总结
一次中级 Web 逆向里,从请求签名到参数还原,真正的核心不是“识别出用了 MD5 还是 SHA256”,而是这条完整链路:
- 抓到目标请求
- 定位请求发起位置
- 找到签名前的真实参数
- 还原排序、过滤、编码、序列化规则
- 识别摘要/加密算法
- 提取 secret、timestamp、nonce 等上下文
- 用独立脚本复现并逐步比对
如果你只记住一句话,我希望是这个:
先还原签名输入,再复现签名输出。
最后给你几个可执行建议:
- 每次分析都先打印“签名原文”
- 固定时间戳和随机数做对比实验
- 不要一上来就全量反混淆
- 把中间态拆出来单独验证
- 遇到失败,优先怀疑参数标准化,而不是摘要算法
只要你把这套流程练熟,绝大多数“带签名接口”的定位难度都会从“完全没头绪”,下降到“只是需要耐心”。
如果你正在练习这类题,建议你下一次就按本文的验证清单,完整走一遍。
真正能拉开差距的,往往不是工具,而是你是否把每一步中间结果都核对清楚。