Web逆向实战:从接口签名分析到自动化还原的完整方法论
做 Web 逆向时,很多人一上来就盯着“加密算法”本身,结果越看越乱。实际上,真正决定效率的不是你会不会背某个 MD5/SHA/AES,而是有没有一套稳定的方法论:先定位、再缩圈、后还原、最终自动化。
这篇文章我会从一个中级开发者/逆向学习者更容易落地的角度,带你走完整个流程:从接口签名分析,到在 Node.js 中自动化复现签名。重点不是“炫技”,而是把事情做成。
说明:本文内容用于学习 Web 安全与前端调试分析方法,请在合法授权范围内使用。
背景与问题
很多站点的接口并不是简单发个 fetch 就能拿到数据,常见会遇到这些情况:
- 请求里带有
sign、token、nonce、timestamp - 参数顺序变了,签名就失效
- 前端代码经过混淆、压缩、webpack 打包
- 签名依赖浏览器环境,如
window、document、navigator - 服务端还会校验时间漂移、重放请求、UA、Referer
初学者常见误区有两个:
- 看到加密字段就直接搜 MD5
- 复制浏览器请求头后机械重放
这两种方式在简单场景下可能蒙对,但在稍复杂的站点上几乎走不通。
更靠谱的思路是:
- 先明确:接口到底校验了什么
- 再搞清:签名输入是什么、顺序是什么、有没有环境依赖
- 最后实现:最小可运行还原代码
前置知识
如果你准备跟着做,建议先具备这些基础:
- 会用 Chrome DevTools 看 Network / Sources
- 知道
fetch/XMLHttpRequest的基本调用方式 - 了解常见摘要算法:MD5、SHA1、SHA256
- 能读基础 JavaScript,知道闭包、对象、数组、字符串处理
- 会用 Node.js 跑脚本
环境准备
本文示例使用以下环境:
- Chrome 或 Edge
- Node.js 16+
- 一个可编辑器,如 VS Code
- 可选:
mitmproxy/Charles/Fiddler - 可选:AST 工具,如
@babel/parser
安装 Node 环境后,可先准备一个目录:
mkdir web-sign-demo
cd web-sign-demo
npm init -y
如果后面要跑服务端验证示例,再安装依赖:
npm install express
先建立整体方法论
在我自己的实战里,签名还原通常按下面 5 步做。顺序很重要,别跳步。
flowchart TD
A[抓包定位目标接口] --> B[识别关键参数 sign/token/t]
B --> C[全局搜索参数生成位置]
C --> D[还原签名输入与算法]
D --> E[脱离浏览器自动化复现]
E --> F[逐步校验与稳定运行]
你会发现,真正难的往往不是“加密”,而是中间的两件事:
- 定位是谁生成了签名
- 判断签名依赖了哪些上下文
背景与问题:一个典型接口长什么样
假设我们看到一个请求:
POST /api/list HTTP/1.1
Content-Type: application/json
{
"page": 1,
"size": 20,
"timestamp": 1720000000,
"nonce": "8f3a2d1c",
"sign": "5b4f2f6d3d8b..."
}
返回结果如果签名不对,通常会是:
{
"code": 403,
"msg": "invalid sign"
}
这时不要急着猜算法,先问 4 个问题:
sign是基于哪些字段算出来的?- 字段有没有排序?
- 是否拼接了某个固定 secret?
- 有没有额外环境值参与,如 UA、Cookie、设备指纹?
核心原理
1. 接口签名的本质
绝大多数前端接口签名,本质上是在做下面几件事之一:
- 参数规范化:把请求参数按特定规则拼成字符串
- 摘要/加密:对字符串做 MD5/SHA/HMAC/AES 等处理
- 附加盐值:拼接固定 secret、版本号、时间戳
- 环境绑定:把 Cookie、UA、设备信息等混进去
一个简化版签名公式可能是:
sign = md5("nonce=xxx&page=1&size=20×tamp=1720000000" + secret)
也可能是:
sign = sha256(sort(params) + "|" + ua + "|" + secret)
2. 为什么前端必须暴露逻辑
很多同学觉得“既然有签名,那一定很安全”。其实从 Web 逆向角度看,只要签名在浏览器里计算,逻辑就一定会暴露到客户端。区别只在于:
- 是不是混淆了
- 是不是拆在多个模块里
- 是不是加了环境校验
- 是不是做了动态下发
也就是说,Web 逆向的核心不是“破解密码学”,而是恢复业务逻辑和执行路径。
3. 逆向分析的三个对象
我一般会把目标拆成三层:
classDiagram
class Request {
+url
+method
+headers
+body
}
class Signature {
+timestamp
+nonce
+sign
+algorithm
+serializeRule
}
class RuntimeEnv {
+window
+document
+navigator
+cookie
+localStorage
}
Request --> Signature
Signature --> RuntimeEnv
这张图的意思很简单:
- Request:你看到的请求长什么样
- Signature:签名字段如何生成
- RuntimeEnv:生成签名时依赖了什么浏览器环境
很多“明明算法找到了但跑不通”的问题,其实都卡在第三层。
实战思路:从抓包到定位签名函数
下面我用一个可运行的简化案例带你走流程。虽然示例是教学版,但流程和真实站点一致。
第一步:在 Network 面板锁定目标请求
先找目标接口,重点看:
- Query String Parameters
- Request Payload
- Request Headers
- Response 中的报错信息
假设我们发现每次请求都有这些字段:
pagesizetimestampnoncesign
这时可以先做一个简单实验:
- 改
page,看sign是否变化 - 保持参数不变只重发,看
nonce/timestamp是否变化 - 删掉
sign看服务端返回什么
第二步:全局搜索关键字段
在 Sources 面板里搜索:
"sign""timestamp""nonce"- 接口路径的一部分,如
"/api/list"
通常能搜到以下线索之一:
data.sign = m(n(data))
或者:
const sign = crypto(params, secret)
或者 webpack 打包后的形式:
r.a = function(e){return i()(o()(e)+c)}
别被这种压缩形式吓到。你要做的是:
- 找到请求发送点
- 往上回溯
sign是在哪一层注入的 - 在关键函数上打断点
第三步:打断点看“签名前原文”
这一招非常关键。很多人花大量时间在猜算法,我更推荐先看签名输入字符串。
例如在生成签名前一行打断点,观察:
const raw = buildQuery(params) + secret;
const sign = md5(raw);
只要你看到了 raw,问题就已经解决了一半。
重点确认:
- 参数是否排序
- 是否过滤空值
- 是否 URL 编码
- 是否拼接固定 secret
- 字符串分隔符是什么:
&、|、,、空串
一个完整的教学案例
下面先构造一个服务端,再写一个客户端去复现它的签名。这样你可以本地完整跑通。
实战代码(可运行)
1. 服务端:校验签名
创建 server.js:
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const SECRET = 'demo_secret_2024';
function buildSign(params) {
const keys = Object.keys(params)
.filter(k => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
.sort();
const query = keys.map(k => `${k}=${params[k]}`).join('&');
return crypto
.createHash('md5')
.update(query + SECRET, 'utf8')
.digest('hex');
}
app.post('/api/list', (req, res) => {
const body = req.body || {};
const clientSign = body.sign || '';
const serverSign = buildSign(body);
if (clientSign !== serverSign) {
return res.status(403).json({
code: 403,
msg: 'invalid sign',
expect: serverSign
});
}
res.json({
code: 0,
data: {
list: [
{ id: 1, name: 'nodejs' },
{ id: 2, name: 'reverse' }
],
page: body.page,
size: body.size
}
});
});
app.listen(3000, () => {
console.log('server running at http://localhost:3000');
});
启动:
node server.js
2. 客户端:自动生成签名并请求接口
创建 client.js:
const crypto = require('crypto');
const SECRET = 'demo_secret_2024';
function randomNonce(len = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let s = '';
for (let i = 0; i < len; i++) {
s += chars[Math.floor(Math.random() * chars.length)];
}
return s;
}
function buildSign(params) {
const keys = Object.keys(params)
.filter(k => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
.sort();
const query = keys.map(k => `${k}=${params[k]}`).join('&');
return crypto
.createHash('md5')
.update(query + SECRET, 'utf8')
.digest('hex');
}
async function main() {
const payload = {
page: 1,
size: 20,
timestamp: Math.floor(Date.now() / 1000),
nonce: randomNonce()
};
payload.sign = buildSign(payload);
const resp = await fetch('http://localhost:3000/api/list', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const data = await resp.json();
console.log('request payload:', payload);
console.log('response:', data);
}
main().catch(console.error);
运行:
node client.js
3. 故意制造错误,验证你的理解
把 buildSign 中的 .sort() 去掉,再执行一次。你大概率会看到:
{
"code": 403,
"msg": "invalid sign"
}
这一步特别重要,因为它能帮你建立一个概念:
签名还原不是“算法对了就行”,而是参数处理规则也必须完全一致。
如何把浏览器里的签名逻辑“搬”出来
真实站点的难点通常不是你自己写签名,而是把网站前端里的逻辑提取出来。
我一般用下面这条路线:
sequenceDiagram
participant U as 你
participant B as 浏览器页面
participant J as JS签名函数
participant S as 服务器
U->>B: 触发接口请求
B->>J: 组装参数并计算sign
J-->>B: 返回sign
B->>S: 发起带sign请求
S-->>B: 校验通过并返回数据
U->>J: 提取/复刻签名逻辑
U->>S: 脱离浏览器自动请求
方法一:直接复刻逻辑
如果签名函数不复杂,比如:
- 参数排序
- 拼接字符串
- MD5/SHA
那最稳妥的方式是直接在 Node.js 里重写。优点是:
- 代码清晰
- 依赖少
- 性能稳定
- 后期维护容易
方法二:执行原始 JS
如果签名逻辑非常绕,且依赖大量原始代码,可以考虑:
- 把目标函数和依赖模块扣出来
- 在 Node.js 中补齐
window、document等最小环境 - 直接执行原始 JS 获取签名
例如:
global.window = global;
global.navigator = {
userAgent: 'Mozilla/5.0 Demo'
};
global.document = {
cookie: ''
};
然后 require('./sign.js') 或 eval 对应代码。
这种方式适合:
- webpack 模块较多
- 算法夹杂大量业务逻辑
- 短期验证优先
但缺点也明显:
- 环境兼容问题多
- 升级后容易失效
- 调试成本高
方法三:浏览器内调用后对外暴露
还有一种折中方案:在浏览器环境中注入脚本,直接调用页面上的签名函数,再把结果通过接口或控制台拿出来。
适合:
- 目标函数强依赖 DOM/Canvas/WebCrypto
- Node.js 模拟成本太高
- 需要快速 PoC
不过从长期自动化角度,优先级通常不如“纯 Node 复刻”。
逐步验证清单
这一节我强烈建议你在真实项目里照着做。很多问题不是不会,而是没建立“逐步收敛”的习惯。
第 1 层:只验证参数结构
确认请求是否至少长得像浏览器请求:
- URL 对不对
- 方法对不对
- Body 字段齐不齐
- Content-Type 是否一致
第 2 层:只验证签名输入串
打印:
console.log(query);
console.log(query + SECRET);
拿它和浏览器断点看到的内容逐字符对比。
第 3 层:验证摘要算法
分别尝试:
- MD5
- SHA1
- SHA256
- HMAC-MD5
- HMAC-SHA256
如果你已经看到原始代码,这一步只是确认,不是瞎猜。
第 4 层:验证环境依赖
观察是否依赖:
navigator.userAgentdocument.cookielocation.hreflocalStorage- 某个动态下发 token
第 5 层:验证时序问题
有些接口会检查:
- 时间戳必须在 5 秒或 60 秒窗口内
nonce不能重复- token 必须先通过前一个接口换取
常见坑与排查
这一节是最容易帮你省时间的。我自己踩过不少坑,很多问题看起来像“算法不对”,其实不是。
1. 参数顺序不一致
最常见。
浏览器可能会:
- 按 key 字典序排序
- 按对象插入顺序
- 按固定白名单顺序
排查方法:
console.log(Object.keys(params));
console.log(Object.keys(params).sort());
别主观判断,一定看原代码。
2. 空值处理不一致
比如前端会过滤:
undefinednull''
但你在 Node 里全拼上去了,签名就不同。
错误示例:
page=1&keyword=&size=20
真实可能应该是:
page=1&size=20
3. URL 编码规则不同
有些站点签名前会先做编码:
encodeURIComponent(value)
有些不会。
尤其是参数包含中文、空格、斜杠、加号时,非常容易翻车。
排查建议:
- 把签名前字符串原样打印
- 把浏览器里的中间结果复制出来逐字符比对
4. 时间戳单位搞错
常见有两种:
- 秒级:
1720000000 - 毫秒级:
1720000000000
看起来差不多,实际直接导致失败。
5. 签名外还有前置 token
很多接口不是只有一个 sign,还会有:
- 先请求
/init拿 token - 再请求业务接口
- token 还要写入 header 或 cookie
也就是说,你看到的 sign 只是校验链中的一环。
6. Node 与浏览器环境差异
原始 JS 里常见这些依赖:
window
document
navigator
atob
btoa
crypto.subtle
如果直接在 Node 跑,可能报:
ReferenceError: window is not defined
解决思路:
- 缺什么补什么
- 只补最小依赖
- 能重写就别硬搬整页代码
7. 混淆代码误导判断
有些混淆代码会把简单逻辑搞得很复杂,比如:
- 字符串数组映射
- 控制流平坦化
- 动态索引访问
- 自执行函数套娃
这时候不要先“读懂全部”,要先找:
- 请求发起点
- 关键参数赋值点
- 最终摘要调用点
抓主干,比通读所有混淆代码有效得多。
一个更贴近实战的“扣函数”示例
假设你在页面里找到了类似函数:
function signPayload(payload) {
var base = Object.keys(payload)
.filter(function(k) {
return payload[k] !== '' && payload[k] != null;
})
.sort()
.map(function(k) {
return k + '=' + payload[k];
})
.join('&');
return md5(base + getSecret() + navigator.userAgent);
}
那么你在 Node 里最小复现时,需要注意的不只是 md5,还有:
getSecret()返回值navigator.userAgent
可以这样还原:
const crypto = require('crypto');
global.navigator = {
userAgent: 'Mozilla/5.0 Demo'
};
function md5(s) {
return crypto.createHash('md5').update(s, 'utf8').digest('hex');
}
function getSecret() {
return 'demo_secret_2024';
}
function signPayload(payload) {
const base = Object.keys(payload)
.filter(k => payload[k] !== '' && payload[k] != null)
.sort()
.map(k => `${k}=${payload[k]}`)
.join('&');
return md5(base + getSecret() + navigator.userAgent);
}
const payload = {
page: 1,
size: 20,
timestamp: 1720000000
};
console.log(signPayload(payload));
这个例子说明一件事:
签名函数本身只是表层,真正要还原的是它的“输入闭环”。
安全/性能最佳实践
这一节站在工程落地角度说,不只是“能跑”,还要“跑得稳”。
1. 优先做“最小还原”
不要一上来把整个站点 JS 都搬进 Node。
更好的做法:
- 只扣签名函数
- 只保留必要依赖
- 只补最小环境
这样好处是:
- 排查链路短
- 性能更好
- 升级后更容易修
2. 建立中间结果日志
建议至少打印这些内容:
console.log('payload:', payload);
console.log('sorted keys:', keys);
console.log('raw string:', query);
console.log('sign:', sign);
生产环境可以做成 debug 开关:
const DEBUG = true;
if (DEBUG) {
console.log({ payload, query, sign });
}
我实际做自动化时,很多问题就是靠“中间态可见”快速定位的。
3. 给签名逻辑写单元测试
尤其是你已经把浏览器逻辑重写成 Node 函数后,最好写几个固定用例。
const assert = require('assert');
const payload = {
page: 1,
size: 20,
timestamp: 1720000000,
nonce: 'abcd1234'
};
const sign = buildSign(payload);
assert.strictEqual(typeof sign, 'string');
assert.strictEqual(sign.length, 32);
console.log('test passed');
这样站点更新时,你会更快知道是哪一层变了。
4. 避免高频重复计算
如果某些 token 或签名在一定时间内可复用,不要每次都重算。
可以做简单缓存:
const cache = new Map();
function getCached(key) {
const item = cache.get(key);
if (!item) return null;
if (Date.now() > item.expire) {
cache.delete(key);
return null;
}
return item.value;
}
function setCached(key, value, ttlMs) {
cache.set(key, {
value,
expire: Date.now() + ttlMs
});
}
适用于:
- 初始化 token
- 配置下发结果
- 短期有效签名种子
5. 注意合法合规与访问边界
这点必须强调:
- 仅在授权范围内分析和验证
- 不要绕过访问控制进行未授权抓取
- 不要对目标服务发起高并发冲击
- 不要泄露或传播他人的签名密钥与内部逻辑
技术能力越强,越要有边界感。
什么时候该“重写”,什么时候该“运行原始 JS”
这是实战中非常常见的取舍问题,我给一个简单判断表。
| 场景 | 建议 |
|---|---|
| 签名逻辑简单、规则明确 | 直接重写 |
| 混淆较轻、依赖少 | 扣函数后运行 |
| 强依赖浏览器 API | 优先浏览器内调用或补环境 |
| 需要长期维护 | 尽量重写 |
| 只是快速验证 PoC | 先跑原始 JS |
我的经验是:
- 短期验证求快:先跑原始 JS
- 长期稳定求维护:尽量重写
一套实战中的排查顺序
如果你现在手里有个真实目标,我建议按这个顺序动手:
- 抓到成功请求
- 记录所有关键字段
- 搜索
sign/ 接口路径 - 在请求发起前打断点
- 找到签名前原文
- 确认参数排序和过滤规则
- 确认算法与盐值
- 确认环境依赖
- 在 Node 里最小复现
- 与浏览器逐步比对中间结果
- 再封装成自动化脚本
这个顺序的核心价值在于:每一步都可验证,失败也知道卡在哪。
总结
Web 逆向里,接口签名分析看上去神秘,实际上可以拆成一条很务实的链路:
- 先从请求中识别关键字段
- 再从前端代码中定位签名生成路径
- 重点观察签名前的原始字符串
- 搞清参数排序、过滤、编码、盐值和环境依赖
- 最后在 Node.js 中做最小自动化还原
真正高效的,不是会多少“算法名词”,而是你能不能形成下面这个习惯:
先定位输入,再确认规则,最后还原执行。
如果你只能记住一句话,我建议记这个:
签名问题八成不是“加密太难”,而是“输入不一致”。
当你把“签名前原文”抓住,整个逆向过程就会从模糊猜测,变成可验证、可复现、可自动化的工程问题。到了这一步,很多原本看起来很玄的站点,其实都能一点点拆开。