从抓包到算法还原:一次典型 Web 逆向中请求签名参数的定位、分析与复现实战
在做 Web 逆向时,最常见、也最让人“卡壳”的点之一,就是接口明明找到了,参数也看着差不多,但一发请求就报错:sign invalid、signature error、illegal request。
这类问题通常不是“接口没找对”,而是请求签名参数没有还原出来。
这篇文章我想按一次典型实战的节奏,带你完整走一遍:
- 怎么从抓包里确认“到底是谁在校验”
- 怎么从前端代码里定位签名生成逻辑
- 怎么判断它是明文拼接、哈希、加密,还是混淆后的组合逻辑
- 怎么在本地把它跑通,最终完成复现
为了让过程更贴近真实场景,下面我会用一个通用但可运行的示例来演示。重点不是某个站点细节,而是这套定位思路和还原方法。
背景与问题
很多 Web 接口都有类似这样的请求:
POST /api/data/list
Content-Type: application/json
{
"page": 1,
"size": 20,
"keyword": "phone",
"ts": 1700000000000,
"sign": "9f1a7b..."
}
看起来业务参数很普通,但只要 sign 不对,服务端就直接拒绝。
我第一次处理这类问题时,最容易犯的错有两个:
- 只盯着请求体,不看请求头和 cookie
- 拿到 sign 就直接猜算法,没先确认依赖项
结果就是浪费大量时间在错误方向上。
一个更靠谱的判断标准
当你看到以下任一特征时,基本可以推断接口有签名校验:
- 请求参数中出现
sign、signature、token、auth、x-s、x-sign - 请求头里有自定义校验字段,如
X-Signature、X-Timestamp - 参数中带时间戳、随机数、nonce、设备指纹
- 相同业务参数下,某些字段每次都变
- 浏览器正常返回,脚本请求却 401/403/参数非法
前置知识
开始前,建议你至少熟悉这些内容:
- Chrome DevTools / 抓包工具的基本使用
- JavaScript 基础语法
- 常见摘要算法:MD5、SHA1、SHA256
- Node.js 基本运行方式
- 浏览器端请求链路:页面事件 → JS 组参 → 发请求
如果你对 AST、webpack、混淆还不熟,也没关系。这篇先聚焦在**“定位 + 验证 + 复现”**这条主线。
环境准备
本文示例使用:
- Chrome DevTools
- Node.js 18+
- 一个文本编辑器
- 可选:Charles / Fiddler / mitmproxy
安装 Node 后,初始化一个目录:
mkdir web-sign-demo
cd web-sign-demo
npm init -y
如果你要做哈希验证,Node 自带 crypto 就够了,不必额外装包。
核心原理
请求签名本质上是在做一件事:
把请求中的关键数据按某种规则处理后,生成一个可校验的摘要值,服务端用同样规则重算并比对。
常见签名流程一般长这样:
flowchart LR
A[业务参数] --> B[参数标准化]
B --> C[拼接固定字段]
C --> D[加入时间戳/nonce/secret]
D --> E[哈希或加密]
E --> F[得到 sign]
F --> G[随请求发送]
这里最关键的不是“它用了 MD5 还是 SHA256”,而是前面的标准化规则:
- 参数是否排序
- 空值是否参与
- 数字和字符串是否做类型转换
- 是否 URL 编码
- 是否只取部分字段
- 是否包含请求路径、请求方法、请求头、cookie
很多时候算法本身不复杂,真正难的是这些“规则细节”。
一个典型签名模型
以最常见的一类前端签名为例:
- 取请求参数对象
- 按 key 升序排序
- 拼接成
key=value&key2=value2 - 追加时间戳与固定 secret
- 做一次 MD5 / SHA256
- 输出十六进制字符串
例如:
keyword=phone&page=1&size=20&ts=1700000000000&secret=demo_secret
然后:
sha256(上面的字符串)
服务端再按同样步骤计算一次。
抓包定位:先确认签名存在与作用范围
别急着进源码。第一步一定是抓包观察变量关系。
观察点 1:哪些字段是动态变化的
打开浏览器 DevTools,进入 Network,筛选目标接口,重点看:
- Query String Parameters
- Request Payload
- Headers
- Cookie
你要做的不是只看“有哪些字段”,而是看:
- 同样操作重复请求时,哪些字段会变化
- 哪些字段变化但响应仍正常
- 哪些字段一改就报错
观察点 2:最小改动实验
一个实用技巧是做“单变量破坏”:
- 保持所有参数不变
- 只改
sign - 看是否立刻报签名错误
- 只改
ts - 看报错是“时间过期”还是“签名错误”
- 只改业务参数
- 看是否会影响签名验证
如果业务参数一改,sign 不变就报错,基本说明:
sign依赖业务参数
如果只改 ts 就报错,说明:
时间戳参与签名,或者服务端还做了时效校验
请求交互关系图
sequenceDiagram
participant U as 浏览器页面
participant J as 前端JS
participant S as 服务端接口
U->>J: 触发查询
J->>J: 组装参数 page/size/keyword/ts
J->>J: 计算 sign
J->>S: 发送请求(参数+sign)
S->>S: 按同规则校验签名
alt 签名正确
S-->>J: 返回业务数据
else 签名错误/时间戳失效
S-->>J: 401/403/参数非法
end
从源码里找 sign:定位方法比盲猜更重要
确认有签名后,下一步才是看前端代码。
方法一:全局搜索关键字
优先搜这些关键词:
signsignaturemd5sha1sha256cryptotimestampnoncetoken
如果站点没混淆,很多时候直接就能搜到类似:
params.sign = getSign(params)
方法二:从请求发起点反推
如果全局搜索没结果,就从请求入口追:
- 搜接口路径,如
/api/data/list - 找到调用它的
fetch/axios/XMLHttpRequest - 看请求发出前,参数在哪里被加工
典型链路经常是:
api.list(data)
→ request.post(url, data)
→ requestInterceptor(config)
→ buildCommonParams()
→ genSign(payload)
方法三:断点比搜索更有效
对于混淆站点,我更推荐直接断点:
- 在 Network 中选中请求
- 右键
Replay XHR或重新触发一次 - 在 Sources 中对
fetch、XHR.send、axios interceptor下断点 - 单步看请求参数何时被补上
sign
有时候源码里变量名全是 _0x1a2b3c,靠搜关键字几乎没法看;但断点一停,作用域里有哪些变量,立刻就清楚了。
识别算法:别一上来就“这是 AES 吧”
找到签名生成函数后,先判断它属于哪一类。
常见类型
1. 明文拼接 + 哈希
最常见,也最容易复现。
特征:
- 出现
sort()、join('&') - 调用
md5()、sha256() - 输出固定长度十六进制字符串
2. HMAC
特征:
- 用到 secret key
- 调用形态像
createHmac('sha256', key) - 输出 hex/base64
3. 对称加密后再编码
特征:
- 出现
AES、CBC、ECB - 有
iv、key - 最终结果是 base64 或长密文
4. 混合逻辑
比如:
- 参数排序后拼接
- 取某几个字段
- 加盐
- 中间做一次编码
- 再 hash
一个实战里最重要的判断
签名字段是否“可逆”不重要,重要的是“生成过程是否可复现”。
大多数情况下,签名不是要“解密”,而是要重跑同样逻辑。
示例目标:还原一个典型签名算法
下面我们构造一个常见前端逻辑,模拟真实逆向场景:
前端源码大致如下:
function normalize(obj) {
return Object.keys(obj)
.filter(k => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
.sort()
.map(k => `${k}=${String(obj[k])}`)
.join('&');
}
function getSign(data, secret) {
const base = normalize(data) + `&secret=${secret}`;
return sha256(base);
}
请求时:
const payload = {
page: 1,
size: 20,
keyword: 'phone',
ts: Date.now()
};
payload.sign = getSign(payload, 'demo_secret');
我们现在要做的,就是在本地完整复现这一过程。
实战代码(可运行)
下面给出一套可直接运行的 Node.js 代码。
1)签名生成脚本
新建 sign.js:
const crypto = require('crypto');
function normalize(obj) {
return Object.keys(obj)
.filter((k) => obj[k] !== undefined && obj[k] !== null && obj[k] !== '')
.sort()
.map((k) => `${k}=${String(obj[k])}`)
.join('&');
}
function sha256(text) {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
}
function getSign(data, secret) {
const base = `${normalize(data)}&secret=${secret}`;
return sha256(base);
}
function buildPayload(input = {}) {
const payload = {
page: input.page ?? 1,
size: input.size ?? 20,
keyword: input.keyword ?? 'phone',
ts: input.ts ?? Date.now(),
};
payload.sign = getSign(payload, 'demo_secret');
return payload;
}
if (require.main === module) {
const payload = buildPayload({
page: 1,
size: 20,
keyword: 'phone',
});
console.log('payload =');
console.log(JSON.stringify(payload, null, 2));
}
module.exports = {
normalize,
getSign,
buildPayload,
};
运行:
node sign.js
你会得到类似输出:
{
"page": 1,
"size": 20,
"keyword": "phone",
"ts": 1700000000000,
"sign": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
2)模拟服务端校验
新建 server.js:
const http = require('http');
const { getSign } = require('./sign');
const SECRET = 'demo_secret';
function send(res, code, data) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(data));
}
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url === '/api/data/list') {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const { sign, ...data } = payload;
if (!sign) {
return send(res, 400, { ok: false, msg: 'missing sign' });
}
const expected = getSign(data, SECRET);
if (expected !== sign) {
return send(res, 403, {
ok: false,
msg: 'invalid sign',
expected,
got: sign,
});
}
const now = Date.now();
if (Math.abs(now - Number(data.ts)) > 5 * 60 * 1000) {
return send(res, 403, {
ok: false,
msg: 'timestamp expired',
});
}
return send(res, 200, {
ok: true,
msg: 'success',
data: {
list: [
{ id: 1, title: 'phone A' },
{ id: 2, title: 'phone B' },
],
},
});
} catch (err) {
return send(res, 500, { ok: false, msg: err.message });
}
});
return;
}
send(res, 404, { ok: false, msg: 'not found' });
});
server.listen(3000, () => {
console.log('server running at http://127.0.0.1:3000');
});
运行:
node server.js
3)客户端请求验证
新建 client.js:
const { buildPayload } = require('./sign');
async function main() {
const payload = buildPayload({
page: 1,
size: 20,
keyword: 'phone',
});
const res = await fetch('http://127.0.0.1:3000/api/data/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await res.json();
console.log(data);
}
main().catch(console.error);
运行:
node client.js
正常会返回:
{
"ok": true,
"msg": "success",
"data": {
"list": [
{ "id": 1, "title": "phone A" },
{ "id": 2, "title": "phone B" }
]
}
}
逐步验证清单
实际逆向时,我建议不要一步到位写完整脚本,而是按下面顺序一项项验证。
第一步:固定原始参数
先把浏览器里成功请求的参数抄下来,尤其是:
- page / size / keyword
- ts
- sign
- headers 中可能参与签名的字段
第二步:只复现明文拼接串
不要急着算 hash,先把待签名字符串打出来。
例如:
console.log(normalize(data) + '&secret=demo_secret');
然后和浏览器中的输入值逐项对比:
- key 顺序对不对
- 值有没有变成字符串
- 是否漏掉空字段
- 是否多带了
sign本身
第三步:验证 hash 结果
确保输入串一致后,再验证摘要结果是否一致。
第四步:放进真实请求中试跑
当 sign 和浏览器一致时,再放进真实请求里验证。
一个非常关键的排查思路:先还原“输入”,再还原“算法”
很多人会陷入这样的误区:
“我知道是 SHA256,为什么结果还是不一样?”
问题往往不在 SHA256,而在输入串不同。
请记住这句话:
逆向签名时,90% 的错误来自输入不一致,而不是算法不一致。
常见输入差异包括:
- 参数顺序不同
- 数字被转成字符串/没转
null、空串是否参与- URL 编码时机不同
- 时间戳位数不同(秒/毫秒)
- 布尔值被转成
true/false还是1/0
常见坑与排查
这一节我尽量讲得“接地气”一点,因为这些坑我基本都踩过。
坑 1:把 sign 自己也参与签名了
错误示例:
payload.sign = getSign(payload, secret);
如果 getSign() 内部没有排除 sign 字段,而你复用旧对象,第二次计算时就会把旧 sign 带进去,结果永远不一致。
排查方法:
- 打印参与签名的最终对象
- 显式剔除
sign
const { sign, ...data } = payload;
坑 2:排序规则和你想的不一样
有的站点不是简单 Object.keys(obj).sort(),而是:
- 只对特定字段排序
- 按字典序但区分大小写
- 按接口文档指定顺序
- 对嵌套对象做递归展开后再排序
如果你的排序错了,hash 结果一定错。
坑 3:时间戳精度不一致
前端常见:
Date.now():毫秒,13 位Math.floor(Date.now() / 1000):秒,10 位
如果站点既做签名,又做过期判断,哪怕算法对了,时间戳精度错也会失败。
坑 4:浏览器环境依赖没补齐
有些签名函数依赖:
windowdocumentnavigatorlocationcanvaslocalStorage
你把函数抠出来在 Node 跑,结果直接报错。
排查建议:
- 先断点确认签名函数真正依赖哪些浏览器对象
- 如果依赖少,手工 mock
- 如果依赖重,考虑在浏览器控制台直接运行,或用
jsdom/ 补环境方案
坑 5:算法前还有一层编码处理
例如:
encodeURIComponent- Base64
- UTF-8/Latin1
- 自定义字符替换
最典型现象是:你看起来“字符串一样”,但 hash 就是不一样。
这时要重点查编码。
坑 6:webpack 打包代码读不动
这是很常见的情况。不要一上来硬啃整包代码。
更高效的办法:
- 找请求入口
- 下断点
- 单步进入生成 sign 的函数
- 在 Console 里直接打印中间变量
比纯静态看包,效率高很多。
还原流程图:从抓包到复现
flowchart TD
A[抓包锁定目标请求] --> B[识别动态字段 sign/ts/nonce]
B --> C[最小改动实验确认依赖关系]
C --> D[从接口路径或请求入口追源码]
D --> E[断点定位签名生成函数]
E --> F[提取参数标准化规则]
F --> G[确认哈希/加密方式]
G --> H[本地复现签名]
H --> I[用真实请求验证]
I --> J[排查边界条件与环境依赖]
安全/性能最佳实践
这里既包括逆向分析时的实践,也包括如果你是接口开发者,应该怎么设计得更稳。
对分析者来说
1. 优先做“中间结果对齐”
不要只比最终 sign,要比:
- 原始参数对象
- 排序后的 key 列表
- 待签名字符串
- 最终摘要值
这样定位最快。
2. 保留每次实验记录
建议你把每次测试记录成表格:
| 项目 | 浏览器值 | 本地值 | 是否一致 |
|---|---|---|---|
| ts | 1700000000000 | 1700000000000 | 是 |
| 参数顺序 | keyword,page,size,ts | keyword,page,size,ts | 是 |
| base string | … | … | 否 |
很多时候,你不是不会,而是比对粒度太粗。
3. 少改、多验证
每次只改一个变量,能极大缩小问题范围。
对接口设计者来说
1. 不要把前端签名当成真正安全边界
这是非常重要的一点。
前端签名的 secret 如果硬编码在 JS 中,本质上是可以被提取的。
它的作用更多是:
- 增加滥用成本
- 过滤低质量脚本
- 做基础风控配合
它不能替代服务端鉴权。
2. 避免过重签名逻辑阻塞主线程
如果每次请求都要在前端做复杂加密、设备指纹采集、大量序列化,页面交互会明显变卡。
可以考虑:
- 简化签名输入
- 使用 Web Worker
- 合理缓存稳定字段
- 避免重复计算
3. 加入 nonce 与时效控制
仅靠固定参数签名,容易被重放。更稳妥的做法是加入:
- 时间戳
- nonce
- 会话绑定字段
- 服务端一次性校验策略
进阶判断:什么时候不是“签名问题”?
有时你把 sign 复现出来了,接口还是失败。别急着怀疑人生,可能问题不在签名。
常见非签名因素:
- cookie 缺失
- CSRF token 缺失
- 请求头顺序/来源校验
- referer / origin 校验
- 人机验证
- 设备指纹
- IP 风控
- 接口权限不足
一个经验判断是:
- 如果报错明确是
invalid sign,优先看签名 - 如果报错是
unauthorized、forbidden、risk control,要扩大排查范围
一套我常用的实战策略
如果你想把这类问题做得更稳,我建议按这个顺序来:
- 抓包确认动态字段
- 做单变量破坏实验
- 从请求入口反推到签名函数
- 断点打印中间字符串
- 先对齐输入串,再对齐摘要
- 最后再移植到 Node/Python 中复现
为什么强调这个顺序?
因为它能避免一开始就陷入“猜算法”。
真实项目里,算法反而往往是最不难的一环,难的是那些隐藏在流程里的细节。
总结
从抓包到算法还原,请求签名复现这件事,核心其实可以浓缩成一句话:
先锁定参与签名的数据和规则,再复现算法本身。
你可以把本文的方法记成一个简单框架:
- 抓包看变化
- 实验找依赖
- 断点定位置
- 打印中间值
- 本地复现
- 真实验证
如果最终结果对不上,优先排查这几项:
- 参数排序是否一致
- 空值与类型转换是否一致
- 时间戳位数是否一致
- 是否误把
sign本身带入计算 - 是否存在编码或浏览器环境依赖
最后再强调一个边界条件:
前端签名可以被还原,但并不代表所有接口问题都能靠“算出 sign”解决。真实场景中,服务端通常还会叠加 cookie、token、风控、人机校验等多层策略。所以当你完成签名复现后,仍要结合整个请求链路来判断问题归因。
如果你正在做自己的第一个 Web 签名逆向,我建议不要追求“一次搞定复杂站点”,而是先把本文这种标准流程练熟。流程稳定了,面对不同站点只是细节变化,思路不会乱。