Web逆向实战:从请求链路分析到关键参数还原的中级方法论
很多人学 Web 逆向,会卡在一个很典型的阶段:
- 抓包会了;
- 请求也能看到;
- 但一到关键参数,比如
sign、token、nonce、timestamp、fingerprint,就开始发懵; - 代码一堆混淆,断点一下全是匿名函数,最后怀疑人生。
如果你也在这个阶段,这篇文章就是写给你的。它不是“某站点一把梭”的题解,而是我更推荐的一种中级实战方法论:先梳理请求链路,再定位参数生成,再做最小还原验证。
这套方法的好处是,面对不同站点时不容易完全失效,迁移性比“背脚本”强很多。
先说明边界:本文内容仅用于授权测试、接口联调、安全研究与学习,请勿用于未授权的数据抓取或绕过访问控制。
背景与问题
在真实业务站点里,前端请求参数很少是“直接写死”的。更常见的是:
- 页面初始化时下发某些配置;
- 浏览器环境参与生成指纹;
- 请求发送前经过一层或多层封装;
- 最终才生成关键参数并发出请求。
也就是说,你抓到的一个请求,往往只是结果,不是原因。
举个常见例子,请求里有这样的参数:
ts=1710000000
nonce=ab12cd34
sign=9f0c7f...
表面看是三个字段,实际上它们背后可能依赖:
- 当前时间戳
- 随机数
- 请求路径
- 请求体摘要
- Cookie 中某个值
- 本地存储中的设备 ID
- 某段动态加载 JS 中的密钥
如果你直接从 sign= 开始全局搜索,常常会陷入两个误区:
- 误区一:只盯结果,不看链路。
- 误区二:一上来就硬扣混淆代码。
我自己早期踩过的坑就是:盯着一个加密函数看了半天,最后发现真正的输入参数在 axios 请求拦截器里早就被改写了,前面白看。
所以,中级阶段更重要的不是“会不会某个加密算法”,而是能不能建立一套可重复执行的分析流程。
前置知识
建议你至少具备这些基础:
- 能使用浏览器开发者工具(Network / Sources / Application)
- 知道 XHR / Fetch 的基本调用方式
- 会看一点 JavaScript
- 知道常见摘要/加密概念:MD5、SHA、AES、RSA、Base64
- 能使用 Python 或 Node.js 做简单脚本验证
如果这些你都没问题,那我们直接进入方法论。
环境准备
本文示例尽量贴近实战,但为了可运行和安全起见,我会用一个简化的本地逻辑演示关键参数还原思路。
推荐环境:
- 浏览器:Chrome
- 调试工具:DevTools、mitmproxy / Fiddler(二选一)
- 代码环境:
- Node.js 18+
- Python 3.10+
- 可选工具:
js-beautifywebpack-bundle-analyzer思路类插件source-map-explorer(如果有 sourcemap)
核心原理
中级 Web 逆向,核心不是“解算法”,而是解决下面这三个问题:
- 请求是谁发起的?
- 参数在哪里被加工?
- 加工所依赖的输入从哪里来?
你可以把它看成一个“从结果倒推源头”的过程。
1. 请求链路分析的本质
一个请求通常会经历这样的路径:
flowchart LR
A[页面事件/初始化] --> B[业务函数]
B --> C[请求封装层]
C --> D[拦截器/中间层]
D --> E[参数构造]
E --> F[加密/摘要/签名]
F --> G[XHR/Fetch发送]
真正有价值的,不只是请求地址,而是这条链路里每一层做了什么。
常见注入点包括:
- 页面按钮点击事件
- 定时拉取任务
- 页面初始化
mounted/useEffect/onload - axios/fetch 的封装函数
- 请求拦截器
- webpack 模块中的工具函数
2. 关键参数还原的三层拆法
我更建议把参数还原拆成三层:
第一层:格式识别
先判断它像什么:
- 32位/40位/64位 hex:可能是 MD5/SHA 系列
- 很长的 Base64:可能是 AES/RSA 输出
- 类似 JSON 再编码:可能是序列化后签名
- 多字段拼接:可能是
path + ts + nonce + body
第二层:输入识别
关注签名前到底喂了什么数据:
- URL 路径?
- Query 参数?
- Body 排序后字符串?
- Cookie / LocalStorage / SessionStorage?
- 浏览器指纹?
- 时间戳与随机数?
第三层:执行位置识别
重点找:
fetch(...)XMLHttpRequest.prototype.sendaxios.create(...)interceptors.request.use(...)CryptoJSwindow.atob/btoaJSON.stringify- 自定义
sign()、encrypt()、getToken()
一套实用的分析流程
这是我在中级实战里最常用的一条流程,从“快定位”出发,而不是一上来就深挖全部代码。
flowchart TD
A[抓到目标请求] --> B[确认请求触发时机]
B --> C[定位调用栈 Initiator]
C --> D[找到请求封装层]
D --> E[观察请求前参数变化]
E --> F[定位sign/token生成函数]
F --> G[分析输入依赖来源]
G --> H[最小脚本还原]
H --> I[与浏览器请求对比验证]
这个流程里,最关键的是两个字:对比。
- 浏览器里真实发出的参数是什么?
- 你脚本里算出来的参数是什么?
- 二者差异到底出在时间、排序、编码、环境还是密钥?
只要能稳定做差异对比,问题通常就能收敛。
从请求链路到参数还原:实战演示
下面我们用一个简化案例来演示。
假设前端发请求前,会生成这样的参数:
ts:秒级时间戳nonce:8位随机串sign:对path|ts|nonce|bodyDigest|secret做 SHA256
前端代码逻辑可能类似这样:
const payload = { keyword: "laptop", page: 1 };
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = randomString(8);
const bodyDigest = sha256(JSON.stringify(payload));
const raw = `/api/search|${ts}|${nonce}|${bodyDigest}|my_secret_key`;
const sign = sha256(raw);
先看链路,不急着抄算法
很多人会直接把 sha256 函数扣走,但中级实战里更重要的是先确认:
- 请求路径是否参与签名?
- body 是否是原始 JSON 还是排序后的 JSON?
ts是秒还是毫秒?nonce是随机还是固定规则?secret是写死、运行时注入,还是接口下发?
一个典型请求时序
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant I as 请求拦截器
participant S as 签名函数
participant N as 网络层
U->>P: 点击搜索
P->>P: 组装payload
P->>I: 调用request(config)
I->>I: 注入ts/nonce
I->>S: 计算bodyDigest与sign
S-->>I: 返回sign
I->>N: 发送最终请求
浏览器里怎么定位
在 DevTools 中建议这样做:
- 打开 Network,找到目标请求;
- 看
Initiator,确认由哪个脚本触发; - 在 Sources 中对请求 URL 片段搜索;
- 搜
fetch(、axios、interceptors.request.use; - 如果
sign名字能搜到最好,搜不到就搜:- 请求 path
- 固定 header
- body 里的业务字段
- 在请求发送前关键位置打断点,看参数变化。
如果站点混淆较重,我通常不会先从“加密函数”开始,而是优先找:
- 请求封装入口
- 请求拦截器
- 最终
send前的 config
因为这里最容易看到“原始输入”和“最终输出”。
实战代码(可运行)
下面给出一个完整可运行的示例。我们用 Node.js 还原前端签名逻辑,再用 Python 发请求模拟。
1)Node.js:签名还原脚本
// sign.js
const crypto = require("crypto");
function sha256(input) {
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
}
function stableStringify(obj) {
if (obj === null || typeof obj !== "object") {
return JSON.stringify(obj);
}
if (Array.isArray(obj)) {
return `[${obj.map(stableStringify).join(",")}]`;
}
const keys = Object.keys(obj).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
}
function randomString(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 buildSignedParams(path, payload, secret, ts = null, nonce = null) {
const finalTs = ts || Math.floor(Date.now() / 1000).toString();
const finalNonce = nonce || randomString(8);
// 注意:这里故意使用稳定序列化,模拟“对象排序后再摘要”的场景
const bodyStr = stableStringify(payload);
const bodyDigest = sha256(bodyStr);
const raw = `${path}|${finalTs}|${finalNonce}|${bodyDigest}|${secret}`;
const sign = sha256(raw);
return {
ts: finalTs,
nonce: finalNonce,
body: bodyStr,
bodyDigest,
sign,
raw
};
}
// 示例运行
const path = "/api/search";
const payload = {
page: 1,
keyword: "laptop"
};
const secret = "my_secret_key";
const result = buildSignedParams(path, payload, secret, "1710000000", "ab12cd34");
console.log(JSON.stringify(result, null, 2));
运行:
node sign.js
你会得到类似输出:
{
"ts": "1710000000",
"nonce": "ab12cd34",
"body": "{\"keyword\":\"laptop\",\"page\":1}",
"bodyDigest": "xxxx",
"sign": "xxxx",
"raw": "/api/search|1710000000|ab12cd34|xxxx|my_secret_key"
}
2)Python:带签名发请求
# client.py
import hashlib
import json
import random
import string
import time
import requests
def sha256(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def stable_dumps(obj):
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def random_string(length=8):
chars = string.ascii_lowercase + string.digits
return "".join(random.choice(chars) for _ in range(length))
def build_signed_params(path, payload, secret, ts=None, nonce=None):
ts = ts or str(int(time.time()))
nonce = nonce or random_string(8)
body = stable_dumps(payload)
body_digest = sha256(body)
raw = f"{path}|{ts}|{nonce}|{body_digest}|{secret}"
sign = sha256(raw)
return {
"ts": ts,
"nonce": nonce,
"sign": sign,
"body": body,
"raw": raw
}
def main():
url = "https://example.com/api/search"
path = "/api/search"
payload = {
"keyword": "laptop",
"page": 1
}
secret = "my_secret_key"
signed = build_signed_params(path, payload, secret)
headers = {
"Content-Type": "application/json",
"X-Ts": signed["ts"],
"X-Nonce": signed["nonce"],
"X-Sign": signed["sign"],
"User-Agent": "Mozilla/5.0"
}
print("RAW:", signed["raw"])
print("BODY:", signed["body"])
print("SIGN:", signed["sign"])
# 示例请求:如果目标服务不存在,请注释掉下面几行
# resp = requests.post(url, headers=headers, data=signed["body"], timeout=10)
# print(resp.status_code)
# print(resp.text)
if __name__ == "__main__":
main()
3)浏览器 Hook:定位参数生成点
如果你还没定位到 sign 是在哪生成的,可以先 Hook 网络层。
Hook fetch
// 在 DevTools Console 中执行
(function () {
const originalFetch = window.fetch;
window.fetch = async function (...args) {
console.log("[fetch args]", args);
debugger;
return originalFetch.apply(this, args);
};
})();
Hook XHR
// 在 DevTools Console 中执行
(function () {
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return open.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
console.log("[xhr]", this._method, this._url, body);
debugger;
return send.call(this, body);
};
})();
这类 Hook 的价值很大:
先拿到最终发送前的数据,再反向找是谁加工出来的。
如何从混淆代码里快速收敛
中级阶段最忌讳的一件事,就是试图一次性“看懂整个 bundle”。没必要,真的没必要。
我一般会用下面这套收敛策略:
1. 先盯“不可变特征”
比如:
- 固定请求路径
/api/search - 固定 header 名
X-Sign - 固定 body 字段
keyword - 固定错误提示,比如
signature invalid
这些字符串通常比函数名更稳定。
2. 再盯“关键时刻”
重点时刻有三个:
- 请求构造前
- 请求发送前
- 响应报错后
因为大多数站点的核心逻辑,都会围绕这几个时刻组织。
3. 最后再看算法本身
有些参数看着复杂,实际只是:
- 排序 + 拼接 + MD5
- JSON 序列化 + SHA256
- AES 加密后 Base64
- RSA 包一下会话密钥
真正难的,往往不是算法,而是输入的完整性和环境依赖。
逐步验证清单
强烈建议你每还原一步,就做一次最小验证,而不是“全部写完再跑”。
验证 1:参数名字对不对
- 是放在 query、header 还是 body?
- 字段名大小写是否一致?
- header 是否是
X-Sign不是x-sign?
验证 2:时间对不对
- 秒级还是毫秒级?
- 服务端允许多大时间偏移?
- 本地时间是否漂移?
验证 3:序列化对不对
JSON.stringify默认顺序,还是 key 排序?- 空格、换行、
ensure_ascii是否影响摘要? - 数字和字符串是否被当成同一种?
验证 4:路径参与方式对不对
- 是
/api/search - 还是完整 URL
- 还是带 query 的完整 path
验证 5:环境依赖是否缺失
- Cookie
- LocalStorage
- SessionStorage
- navigator 信息
- 屏幕尺寸/时区/语言
常见坑与排查
这一节我尽量说一些实战里最常见、最容易让人误判的问题。
坑 1:你还原的是“算法”,不是“输入”
这是最典型的。
比如你确认用了 SHA256,但算出来不对。问题通常不是 SHA256 算错了,而是输入串少了东西,比如:
- 少了
nonce - body 排序不一致
- 拼接符号不是
|而是: - 路径带 query
- secret 不是真正的 secret
排查建议: 把浏览器端签名前的原始字符串打印出来,和脚本里的逐字符对比。
坑 2:对象序列化顺序不同
前端:
JSON.stringify({b:2, a:1})
你在 Python 里:
json.dumps({"b": 2, "a": 1})
如果没做 sort_keys=True,也许序列化结果不同;即便排序了,也可能分隔符不同。
排查建议: 永远打印“参与签名的最终字符串”,别只打印对象。
坑 3:时间窗口导致偶发成功
有些接口允许 ts 偏差 5 秒、10 秒、30 秒。
你本地偶尔成功,不代表逻辑对了,可能只是运气好。
排查建议:
固定 ts 和 nonce 做重复实验,消掉随机因素。
坑 4:请求体和签名体不是同一个东西
有些站点是:
- 实际发送的 body 是加密后的;
- 但签名时用的是加密前的原文。
也有反过来的:
- 发送明文;
- 但签名的是压缩/编码后的字符串。
排查建议: 在“最终请求发送前”打断点,看 config 中到底存了几份 body。
坑 5:密钥是动态下发的
你看到代码里写着 secret = t(0x1a3),以为是常量;结果它其实来自:
- 首屏接口
- 内嵌 script
- cookie 解码
- 动态 JS 文件
排查建议: 给密钥变量打读写断点,追它第一次赋值的位置。
坑 6:环境检测触发了降级逻辑
一些站点会检测:
- DevTools 是否打开
- webdriver 痕迹
- headless 特征
navigator.pluginscanvas/webgl指纹差异
一旦触发,前端可能返回不同参数、不同算法,甚至直接伪造请求失败。
排查建议: 先在真实浏览器里把链路走通,再考虑脚本化迁移。
安全/性能最佳实践
这部分很重要。Web 逆向不是“跑通就行”,尤其在实际工程里,安全与性能意识会直接影响你的成功率和稳定性。
安全最佳实践
1. 严格限定授权边界
只分析你有权测试、调试、联调的系统。
不要对未授权目标做参数绕过、批量调用、风控规避。
2. 不在生产环境直接打破坏性 Hook
有些 Hook 会影响页面正常流程,甚至污染全局对象。
建议:
- 优先在本地副本、测试环境或只读分析环境中做;
- Hook 后记得恢复原始函数;
- 尽量最小化修改范围。
3. 不泄露密钥与用户敏感信息
调试日志里常常会打印:
- token
- cookie
- userId
- phone
- sessionKey
写脚本、记笔记、发 issue 时都要脱敏。我自己以前就见过有人把完整 Cookie 贴到群里,真的很危险。
性能最佳实践
1. 先做最小还原,不要过早“全自动化”
一开始只要证明:
- 你能稳定得到正确
sign - 你能构造出正确请求
就够了。
不要还没弄清参数逻辑,就上来写完整爬虫框架。
2. 给验证脚本加缓存与固定输入
比如:
- 固定
ts - 固定
nonce - 固定
payload - 缓存中间结果
这样可以显著降低排查成本。
3. 拆分“算法层”和“请求层”
推荐结构:
signer:只负责签名client:只负责发请求env:只负责环境依赖获取
这样当站点改版时,你能快速判断是:
- 算法变了;
- 参数位置变了;
- 还是环境来源变了。
一种更稳的工程化组织方式
如果你准备长期维护某个目标的联调或安全测试脚本,建议按下面方式组织:
classDiagram
class EnvProvider {
+get_cookie()
+get_local_storage()
+get_device_id()
}
class Signer {
+build_raw(path, payload, env)
+sign(raw)
}
class ApiClient {
+build_headers(sign_result)
+post(url, payload)
}
EnvProvider --> Signer
Signer --> ApiClient
这样做的好处是:
- 环境变化时,只改
EnvProvider - 签名逻辑变化时,只改
Signer - 接口路径/请求方式变化时,只改
ApiClient
这比把所有逻辑塞进一个脚本里稳得多。
一个实战判断:什么时候该继续深挖,什么时候该止损
这个经验我觉得很值钱。
值得继续深挖的情况
- 已经定位到请求封装层
- 能看到签名前原始数据
- 算法和依赖基本可枚举
- 差异已经收敛到 1~2 个字段
这时候继续挖,大概率能打通。
应该先止损换思路的情况
- 代码动态下发非常重
- 强依赖浏览器环境且检测严格
- 每次刷新算法都变
- 关键逻辑跑在 Wasm / Native bridge
- 请求虽能发,但服务端风控远强于参数校验
这种情况下,继续硬抠一个 sign,收益可能很低。
更好的办法可能是:
- 回到浏览器自动化环境验证;
- 先建立数据面观测;
- 先确认是否真有必要做完整还原。
中级方法论的一个重要标志,不是“死磕”,而是知道何时切换战术。
总结
把这篇文章压缩成一句话,就是:
Web逆向的中级突破,不在于多会几种加密算法,而在于能从请求链路出发,稳定定位参数生成位置,并做最小可验证还原。
你可以记住这套主线:
- 先抓请求,确认触发时机
- 再看 Initiator,找到调用栈
- 定位请求封装层与拦截器
- 观察参数在发送前如何变化
- 识别签名输入、格式与依赖
- 用最小脚本做固定输入还原
- 逐项对比浏览器结果,收敛误差
如果你现在正卡在某个 sign 或 token 上,我给你的最可执行建议是:
- 不要先抠算法,先找链路;
- 不要只看结果,打印原始输入串;
- 不要一次性自动化,先做最小验证;
- 不要忽视序列化、时间戳、环境依赖这些“小问题”。
很多时候,真正拦住你的不是“加密太强”,而是分析顺序不对。
当你把“请求链路分析 → 参数生成定位 → 最小还原验证”这条线走顺了,面对新站点时,你会明显感觉:
虽然细节不同,但思路终于是自己的了。