Web逆向实战:从浏览器抓包到还原加签逻辑的完整分析方法
做 Web 逆向时,很多人一上来就盯着混淆 JS 猛看,结果看了半天还是不知道签名到底怎么来的。
我自己早期也吃过这个亏:代码打开几千行,变量名全是 _0x12ab,看得头皮发麻,最后发现其实只要先把请求链路理清,定位到“谁在什么时候生成了什么”,难度会直接下降一个量级。
这篇文章我不打算只讲“怎么抄代码”,而是带你走一遍更稳定的分析路径:从浏览器抓包开始,逐步定位请求、还原参数、确认加签输入、复现签名输出,最后用脚本跑通。
说明:本文用于学习 Web 安全研究、协议分析和前端调试方法。请仅在合法授权范围内使用。
背景与问题
现在很多 Web 接口为了防止被随意调用,都会加上类似下面这些字段:
signtokentimestampnoncex-sx-signauthorization- 自定义请求头里的摘要值
从现象上看,你会遇到这些问题:
- 同样的接口,浏览器里能成功,请求工具里却返回“签名错误”
- 参数明明一样,但服务端还是提示“非法请求”
- 复制了请求头,过几秒就失效
- 看起来是明文参数,实际上关键值在运行时拼接
- 加签逻辑不在主业务代码里,而是在 webpack 模块、闭包、hook 包装函数甚至 WebAssembly 里
所以真正的核心问题不是“如何看懂混淆代码”,而是:
- 这个签名是对哪些数据做的?
- 签名生成发生在请求流程的哪个阶段?
- 是同步生成还是异步依赖其他状态?
- 签名依赖固定密钥、动态 token、Cookie 还是设备指纹?
前置知识
建议你至少熟悉这些内容:
- 浏览器开发者工具(Network / Sources / Console)
- 基本 HTTP 请求结构
- JavaScript 基础语法
- 常见摘要算法概念:MD5 / SHA1 / SHA256 / HMAC
- Node.js 基本运行方式
如果你会一点这些工具会更顺手:
- Chrome DevTools
- Fiddler / Charles / mitmproxy
- Node.js
- Python requests
- Pretty Print、全局搜索、XHR/fetch 断点
环境准备
本文演示尽量用通用方式,不依赖特定站点。
建议环境:
node -v
python --version
google-chrome --version
常用工具清单:
- Chrome 浏览器
- Node.js 16+
- Python 3.9+
- 一个抓包代理工具(可选)
- 文本编辑器或 IDE
核心原理
从浏览器抓包到还原加签,本质上是在回答一条链路上的 4 个问题:
- 请求发给谁
- 请求带了什么
- 这些参数在前端哪里生成
- 生成规则能否脱离浏览器独立复现
一个最实用的分析框架
我通常按下面这个顺序做:
flowchart TD
A[浏览器触发请求] --> B[Network定位目标接口]
B --> C[比对请求参数/Header/Cookie]
C --> D[识别动态字段 sign ts nonce token]
D --> E[Sources全局搜索字段名]
E --> F[定位加签函数]
F --> G[分析输入 拼接顺序 编码方式]
G --> H[脚本复现签名]
H --> I[验证请求可独立重放]
这个流程的好处是:
先找证据,再找代码;先缩小范围,再啃逻辑。
常见加签模式
Web 场景里常见的签名,大致有这些套路:
- 简单拼接后摘要
md5(path + ts + secret)
- 参数排序后摘要
sha256(k1=v1&k2=v2&ts=xxx + secret)
- HMAC
hmac_sha256(payload, secret)
- 请求体摘要 + token 混合
sign = md5(bodyDigest + token + ts)
- 多段来源混合
- URL 参数 + body + cookie + localStorage 值
- 前端环境参与
- User-Agent、屏幕参数、Canvas 指纹、随机数
签名分析的三个关键点
1. 输入到底有哪些
这是最容易漏掉的地方。
很多人只看 body,结果签名实际上还依赖:
- 路径:
/api/v1/search - 查询串:
page=1&q=test - 时间戳:
ts - 随机串:
nonce - Cookie 中的某个会话值
- localStorage / sessionStorage 中的 token
- 某个固定版本号
2. 参数顺序和编码方式
即使参数一样,顺序和编码不同,签名也完全不同。常见差异有:
- 是否按 key 排序
- 是否过滤空值
- 是否 URL encode
- 是否 JSON stringify
- stringify 时 key 顺序是否固定
- 拼接分隔符是
&、,、|还是空字符串
3. 时效性与上下文
有些签名不是单独一个函数就够了,而是依赖上下文状态:
- token 是否已初始化
- 页面加载后是否下发 seed
- sign 是否与当前 Cookie 绑定
- 时间戳是否必须在 5 秒窗口内
- 服务端是否校验 nonce 去重
抓包与定位:先缩小问题范围
第一步:在 Network 面板找目标请求
先打开浏览器开发者工具,切到 Network,然后操作页面触发接口。
优先观察这些项:
- Request URL
- Request Method
- Query String Parameters
- Form Data / Request Payload
- Request Headers
- Response
如果你已经怀疑有加签,重点盯:
- URL 参数里是否有
sign、ts、nonce - Header 里是否有
x-sign、authorization - Cookie 是否参与身份绑定
第二步:多抓几次,做对比
抓一条请求往往不够,建议至少抓 3 次。
对比方法很简单:只改变一个变量,然后看哪些字段变了。
例如:
- 同样参数,隔 2 秒再请求一次
- 改一个查询参数
- 切换页码
- 登录前后各抓一次
这样你就能判断:
- 哪些值是时间相关
- 哪些值是参数相关
- 哪些值是身份相关
第三步:构造“输入-输出”样本
举个例子,你可能得到下面这种观察结果:
| 次数 | q | page | ts | sign |
|---|---|---|---|---|
| 1 | phone | 1 | 1710000001 | a1b2c3… |
| 2 | phone | 1 | 1710000003 | d4e5f6… |
| 3 | case | 1 | 1710000005 | 91aa2b… |
这说明至少:
sign与ts有关sign与q也有关- 可能还和固定 secret 或 token 有关
如何在 JS 里定位加签逻辑
找到请求之后,下一步不是漫无目的搜代码,而是针对“动态字段”做反查。
方法一:全局搜索字段名
最先搜这些词:
signnoncetimestampx-sign- 请求路径关键字
- 某个固定 header 名
如果字段名没有直接出现,可能是压缩后被改写了,那就继续用下面的方法。
方法二:给 XHR/fetch 打断点
很多站点现在用 fetch,有些还混着 XMLHttpRequest。
你可以在 DevTools 的 Sources 面板中设置:
- XHR/fetch Breakpoints
- 对某个 URL 关键字打断点
一旦请求发出,执行会停在调用栈附近。
这个位置非常重要,因为你能看到:
- 请求参数在发送前的最终值
- 调用栈上层是谁组装了 sign
- 某个 header 是在哪一层注入的
方法三:Hook 关键 API
如果代码过于混乱,我通常会直接在 Console 里 hook。
下面这个脚本可以监听 fetch:
(function () {
const rawFetch = window.fetch;
window.fetch = async function (...args) {
const [url, config] = args;
console.log("fetch url:", url);
console.log("fetch config:", config);
debugger;
return rawFetch.apply(this, args);
};
})();
如果站点走 XHR,可以 hook open 和 send:
(function () {
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return rawOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
console.log("xhr method:", this._method);
console.log("xhr url:", this._url);
console.log("xhr body:", body);
debugger;
return rawSend.call(this, body);
};
})();
这类方法的优势是:
不需要先看懂所有源码,就能把请求发起前的真实数据抓出来。
实战案例:还原一个典型前端加签流程
下面我用一个“教学型示例”来演示完整思路。
假设页面会请求:
POST /api/search
Content-Type: application/json
X-Token: user_token_abc
X-Sign: 9f1d...
{"q":"phone","page":1,"ts":1710000001}
我们通过抓包和断点观察,发现签名逻辑大致是:
- 取请求体 JSON 字符串
- 取请求头里的
X-Token - 取固定版本号
v1 - 按
body + "|" + token + "|" + version拼接 - 对拼接结果做 SHA256,输出十六进制小写
还原流程图
sequenceDiagram
participant U as 用户操作
participant B as 浏览器页面
participant S as 加签函数
participant A as 接口服务端
U->>B: 点击搜索
B->>B: 组装 payload(ts/q/page)
B->>S: 传入 body + token + version
S-->>B: 返回 X-Sign
B->>A: POST /api/search + X-Sign
A-->>B: 校验成功并返回结果
浏览器侧模拟代码
先写一个与页面逻辑一致的前端版本:
async function sha256Hex(text) {
const data = new TextEncoder().encode(text);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}
async function buildSign(body, token, version = "v1") {
const raw = `${body}|${token}|${version}`;
return await sha256Hex(raw);
}
async function demo() {
const payload = {
q: "phone",
page: 1,
ts: 1710000001
};
const body = JSON.stringify(payload);
const token = "user_token_abc";
const sign = await buildSign(body, token);
console.log("body:", body);
console.log("sign:", sign);
}
demo();
Node.js 可运行复现脚本
实际脱离浏览器跑任务时,我更常用 Node.js:
const crypto = require("crypto");
function buildSign(body, token, version = "v1") {
const raw = `${body}|${token}|${version}`;
return crypto.createHash("sha256").update(raw, "utf8").digest("hex");
}
function buildRequestData(q, page, token) {
const payload = {
q,
page,
ts: Math.floor(Date.now() / 1000)
};
const body = JSON.stringify(payload);
const sign = buildSign(body, token);
return {
payload,
headers: {
"Content-Type": "application/json",
"X-Token": token,
"X-Sign": sign
}
};
}
const token = "user_token_abc";
const result = buildRequestData("phone", 1, token);
console.log(JSON.stringify(result, null, 2));
Python 请求复现
如果你的后续流程在 Python 中,可以这样写:
import time
import json
import hashlib
import requests
def build_sign(body: str, token: str, version: str = "v1") -> str:
raw = f"{body}|{token}|{version}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def send_request():
url = "https://example.com/api/search"
token = "user_token_abc"
payload = {
"q": "phone",
"page": 1,
"ts": int(time.time())
}
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
sign = build_sign(body, token)
headers = {
"Content-Type": "application/json",
"X-Token": token,
"X-Sign": sign
}
resp = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=10)
print(resp.status_code)
print(resp.text)
if __name__ == "__main__":
send_request()
这里我特意用了:
json.dumps(payload, separators=(",", ":"))
因为很多签名对 JSON 字符串的空格都敏感。
你在浏览器里看到的是对象,服务端校验的往往是序列化后的精确字符串。
逐步验证清单
做这类逆向时,不要一口气“猜完整逻辑”,而要逐步验证。
第 1 步:验证签名输入是否找全
你可以先固定其它值,只改一个字段:
- 只改
q - 只改
page - 只改
ts - 只改 token
看签名是否变化,确认依赖关系。
第 2 步:验证拼接顺序
比如你怀疑是:
body|token|v1
也可以尝试:
token|body|v1
body|v1|token
如果站点逻辑比较简单,这一步很快就能撞出来。
第 3 步:验证编码一致性
重点检查:
- UTF-8 还是其他编码
- 十六进制大小写
- Base64 是否去掉
= - JSON 是否有空格
- 参数是否 URL encode
第 4 步:验证是否还有隐含上下文
如果单独脚本算出来的 sign 与浏览器一致,但请求还是失败,问题大概率在:
- Cookie 没带全
- Referer / Origin 被校验
- 服务端还校验 token 与 sign 的绑定关系
- 时间戳窗口过期
- 某个 nonce 已被服务端消费
复杂场景下的定位策略
有些站点不会把签名函数直接摆在你面前,而是做了很多包装。
场景一:webpack 打包 + 模块化很深
特征:
- 代码都在匿名函数和模块编号里
- 一个请求要跨好几个模块调用
建议做法:
- 对请求 URL 打 XHR/fetch 断点
- 看调用栈
- 从最接近请求发送的位置往上追
- 找出“最后一次赋值 sign”的地方
场景二:代码混淆严重
特征:
- 变量名不可读
- 大量数组映射
- 控制流平坦化
建议做法:
- 不先做全文阅读
- 先 hook
fetch/xhr - 再 hook
crypto相关函数 - 记录调用入参和返回值
比如 hook JSON.stringify 或摘要函数附近逻辑:
(function () {
const rawStringify = JSON.stringify;
JSON.stringify = function (...args) {
const result = rawStringify.apply(this, args);
console.log("JSON.stringify input:", args[0]);
console.log("JSON.stringify output:", result);
return result;
};
})();
场景三:签名依赖异步初始化
特征:
- 页面刚打开时请求失败,过一会儿成功
- 某个 token 是接口先下发的
- sign 依赖动态 seed
这种情况要先梳理状态机:
stateDiagram-v2
[*] --> Init
Init --> GetSeed: 页面加载
GetSeed --> SeedReady: seed/token下发成功
SeedReady --> BuildSign: 用户触发请求
BuildSign --> SendRequest
SendRequest --> [*]
如果你跳过 GetSeed 直接模拟最终接口,往往一定失败。
常见坑与排查
这一部分很重要,我尽量写得“接地气”一点。
1. 只看请求参数,不看 Header
很多签名其实根本不在 body,而是在 Header。
尤其是:
authorizationx-signx-tx-sx-token
排查建议:
抓包时把 Request Headers 展开,不要只盯 Payload。
2. 误把“显示值”当“参与签名的值”
有些参数在页面对象里长这样:
{
q: "phone",
page: 1
}
但实际参与签名的是:
'{"page":1,"q":"phone"}'
也可能是排序后的 querystring:
page=1&q=phone
排查建议:
- 看签名前最后一次字符串化结果
- hook
JSON.stringify - hook URLSearchParams 的生成逻辑
3. 时间戳精度搞错
常见差异:
- 秒级:
1710000001 - 毫秒级:
1710000001000
有些接口还会把时间戳转成字符串再参与拼接。
排查建议:
- 抓两次请求,算一下差值
- 看数值长度是 10 位还是 13 位
4. JSON 序列化不一致
这是我自己踩过很多次的坑。
浏览器里可能是:
JSON.stringify(payload)
而你 Python 默认 json.dumps 会带空格,结果签名永远不对。
排查建议:
json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
并确认 key 顺序是否一致。
5. 忽略 Cookie / localStorage 依赖
有些 sign 本身没问题,但服务端还会做“上下文绑定”:
- sign 对应的 token 来自 localStorage
- token 对应的会话来自 Cookie
- Cookie 不匹配就报签名错误
排查建议:
在浏览器 Console 查看:
localStorage
sessionStorage
document.cookie
并对照请求头检查是否全部带上。
6. 复制代码后在 Node 中直接运行失败
原因往往是浏览器环境对象缺失:
windowdocumentnavigatorcrypto.subtleatob/btoa
排查建议:
- 先最小化抽离:只提取真正的签名函数
- 必要时做环境补丁
- 不要一开始就搬整个混淆文件进 Node
7. 抓到的是“中间值”,不是最终值
例如某个函数先算出一个摘要 A,后面又做了一层编码或拼接,最终发出去的是 B。
只盯住前半段,很容易误判“算法已经还原”。
排查建议:
最终以 Network 里真正发出的值 为准,逐层对照。
安全/性能最佳实践
这部分不只是“怎么逆”,也包括怎么更稳地做分析和复现。
1. 先证据链,后代码阅读
不要一上来就打开大文件硬啃。
正确顺序应该是:
- 抓包
- 对比样本
- 定位动态字段
- 断点/Hook
- 抽离函数
- 脚本复现
这样效率高很多。
2. 保留原始样本
建议你保存:
- 原始请求 URL
- Header
- Cookie
- Payload
- 返回结果
- 时间点
最好整理成一个小目录。
后面复现失败时,你才能快速比对“是算法错了,还是上下文缺了”。
3. 抽离最小可用签名函数
做脚本化复现时,目标不是把整站前端复制下来,而是提取最小依赖集。
理想状态下,你最终只保留:
- 一个
buildSign - 一个
buildPayload - 一个
sendRequest
这样可维护性最好。
4. 控制请求频率
即使你已经还原了签名,也不要高频压接口。
很多站点有:
- IP 限流
- 用户行为风控
- nonce 重放检测
- 时间窗口校验
频率过高会触发风控,让你误以为“签名算法错了”。
5. 记录每一步验证结果
我个人很推荐你用表格或日志记:
- 输入参数
- token
- ts
- 原始拼接串
- 中间摘要
- 最终 sign
- 服务端返回
一旦失败,能迅速回滚到上一层排查。
一套可复用的分析模板
如果你想把这套方法固化成习惯,可以按这个模板执行:
flowchart LR
A[抓3次包] --> B[找动态字段]
B --> C[判断字段依赖关系]
C --> D[URL关键字断点]
D --> E[定位签名生成函数]
E --> F[确认输入与顺序]
F --> G[确认编码与摘要算法]
G --> H[Node/Python复现]
H --> I[带Cookie/Token完整验证]
对应的落地动作是:
- 抓三次包,做对比
- 锁定动态字段
- 给请求断点
- 看调用栈
- 抽出签名函数
- 用固定样本验证
- 再接入动态时间戳与 token
- 最后发真实请求验证
总结
Web 逆向里最怕的不是混淆,而是没有方法。
只要你按“抓包定位 → 识别动态字段 → 断点追调用栈 → 确认输入与编码 → 脚本复现”的路径走,大多数前端加签都能拆开。
你可以记住这几个关键结论:
- 先抓包,再看代码
- 先确认签名输入,再猜算法
- 最终以网络面板里真正发出的值为准
- JSON、排序、编码、时间戳精度,是最常见的坑
- 能抽最小函数就不要整包迁移
如果你现在就要动手,我建议从下面这个最小行动清单开始:
- 抓同一接口 3 次包
- 标出每次变化的字段
- 对目标 URL 打 XHR/fetch 断点
- 找到 sign 最终赋值点
- 打印签名前的原始拼接字符串
- 用 Node.js 独立复现并对比浏览器结果
当你能稳定做到这 6 步时,很多看起来“很玄学”的加签逻辑,其实都会变得非常具体。
这也是 Web 逆向真正的门槛:不是某个神秘算法,而是你能不能把过程拆清楚、验证清楚。