Web逆向实战:从请求链路分析到签名参数复现的中级方法论
做 Web 逆向时,很多人一上来就盯着“加密函数”本身,结果越看越乱。真正高效的方式,往往不是先解算法,而是先把请求链路理清:参数从哪里来、什么时候组装、在哪一层被改写、最终如何落到网络请求里。
这篇文章我会按一个更接近实战的顺序,带你从 请求链路分析 走到 签名参数复现。重点不是某个具体站点,而是一套中级阶段非常实用的方法论:先定位链路,再缩小范围,最后最小化复现。
边界先说清:本文讨论的是安全研究、接口调试、自动化测试、数据抓包分析等合法场景。请仅在你有权限的系统中使用。
背景与问题
一个典型场景是这样的:
- 页面里某个接口请求返回 401 / 403
- 你复制请求头、Cookie、Query 参数去重放,仍然失败
- 多了几个看不懂的字段,比如:
signtokenx-straceidnoncetimestamp
这时很多人会立刻搜“JS 逆向 sign”,然后在 Sources 里全局搜 md5、sha256、encrypt。这当然可能有效,但中级阶段更应该建立一个稳定套路:
- 先确认哪个参数真的是校验核心
- 再确定参数生成时机
- 再确定参与签名的原始数据
- 最后才是还原算法和执行环境
如果这四步顺序反了,往往会陷入两个坑:
- 误把业务参数当签名参数
- 误把加密函数当核心,而忽略前置拼接、排序、编码、环境注入
我当时踩过一个很典型的坑:盯着一个 sha1() 半天,最后发现真正影响结果的是调用前对对象做了字段排序 + URL 编码 + 空值过滤。哈希函数本身根本不重要,重要的是“喂进去的字符串到底是什么”。
前置知识
如果你已经具备下面这些基础,阅读会顺很多:
- 会用浏览器开发者工具看 Network / Sources / Application
- 知道 XHR / Fetch 的基本区别
- 理解 Query 参数、Form、JSON Body
- 对 JavaScript 闭包、异步 Promise、Hook 有基本认知
- 能运行 Node.js 或 Python
环境准备
建议准备以下工具:
- Chrome / Edge DevTools
- Node.js 16+
- Python 3.9+
- 可选:
- Charles / Fiddler / mitmproxy
- JS 格式化工具
- source map 查看工具
我个人偏好是:
- 浏览器里定位链路
- Node.js 里复现 JS 签名
- Python 里做自动化请求
这样职责清晰,不容易把“分析环境”和“请求环境”混在一起。
核心原理
1. Web 逆向的重点不是“算法”,而是“数据流”
签名参数一般是由以下几部分拼出来的:
- 固定盐值或版本号
- 时间戳
- 随机数 / nonce
- 设备指纹 / 环境特征
- 业务参数(如页码、关键词、商品 ID)
- Cookie / LocalStorage / SessionStorage 中的令牌
- 服务端下发的动态挑战值
所以你要还原的其实不是单个函数,而是这条数据流:
flowchart LR
A[页面交互/初始化] --> B[业务参数收集]
B --> C[环境参数注入]
C --> D[签名串拼接]
D --> E[哈希/加密]
E --> F[请求头或请求参数]
F --> G[发送请求]
2. 请求链路分析的目标
请求链路分析不是为了“看懂所有代码”,而是为了回答这几个关键问题:
- 请求是谁发起的?
- 参数在哪一层组装?
- 签名在发送前最后一次修改发生在哪里?
- 哪些字段是稳定的,哪些字段是一次一变的?
- 是否依赖浏览器环境对象?
3. 典型签名生成位置
常见位置通常有三层:
- 业务层
- 页面代码直接拼接参数
- 请求封装层
- axios/fetch 二次封装、request interceptor
- 安全 SDK 层
- 专门负责 sign/token/fingerprint
中级实战里,一个很重要的意识是:
真正的签名逻辑往往不在发请求的页面代码里,而在拦截器或 SDK 里。
从请求链路入手的分析框架
第一步:在 Network 里确认“目标请求”
先找出最值得分析的那个请求,通常满足:
- 只有它失败,其他静态资源正常
- 重放时服务端会校验
- 请求里带有明显动态字段
- 接口响应对业务价值高
拿到目标请求后,优先记录:
- 请求方法
- URL
- Query 参数
- Header
- Body
- Cookie
- 请求发起时间
- 调用栈 Initiator
第二步:按“发起点”反查调用链
Chrome DevTools 的 Initiator / Call Stack 很关键。
你要找到的是:
- 谁调用了
fetch - 谁调用了
XMLHttpRequest.send - 谁在请求发送前改写 headers/body
可以把调用链抽象成这样:
sequenceDiagram
participant U as 用户操作
participant P as 页面业务代码
participant R as 请求封装层
participant S as 签名模块
participant N as Network
U->>P: 点击/滚动/加载
P->>R: request(config)
R->>S: buildSign(config, env)
S-->>R: sign/header/token
R->>N: fetch/xhr send
第三步:不要急着搜 md5,先搜“参数名”
如果请求中有 sign、x-sign、signature,优先全局搜索:
- 参数名本身
- 设置 header 的方法
setRequestHeaderfetch(url, config)的headers- axios 请求拦截器
我很多次都是这样定位到的,而不是直接搜加密函数。
因为加密函数可能被混淆成 a.b(c),但参数名通常要落到请求里,藏不住。
第四步:确认“签名原文”
这是最容易被忽略、也是最重要的一步。
你需要确认:
- 参与签名的字段有哪些
- 字段顺序是否固定
- 是否过滤空值
- 是否把对象转为 JSON 后再签
- 是否 URL 编码
- 是否拼接 secret
- 是否区分大小写
- 时间戳单位是秒还是毫秒
很多“明明算法一样却签不对”的问题,都出在这里。
一套可执行的中级方法论
我习惯把整个过程拆成 5 个层次:
层 1:抓到“最终请求”
目的是拿到结果,而不是理解代码。
关注:
- 最终发出的 headers
- 最终 body
- 动态参数随时间是否变化
层 2:定位“最后写入点”
目的是找到“是谁把 sign 写进去的”。
常用手法:
- 对
fetch打断点 - 对 XHR 的
send打断点 - Hook
setRequestHeader - Hook
JSON.stringify
层 3:定位“签名原文组装点”
目的是弄清楚进入加密函数前的字符串。
常用手法:
- 在疑似函数前后打印入参与出参
- 对
CryptoJS.MD5、sha256、btoa等做 Hook - 断点观察调用栈和闭包变量
层 4:抽离“最小可复现逻辑”
目的是在页面外运行。
抽离时只保留:
- 必需参数
- 必需依赖函数
- 必需环境对象
不要一开始就复制整份混淆代码,那样后期很难维护。
层 5:重放验证与批量化
目的是确认复现结果真的有效。
验证顺序建议是:
- 浏览器控制台内调用函数,结果是否一致
- Node.js 中运行,结果是否一致
- 替换到真实请求里,是否通过
- 批量请求时,是否稳定通过
实战案例:复现一个常见签名参数
下面用一个教学用简化案例演示完整流程。
假设某接口请求如下:
GET /api/list?page=1&keyword=phone&ts=1665830000&sign=?
页面逻辑大致是:
- 取
page、keyword - 加上当前时间戳
ts - 按键名排序
- 拼成
keyword=phone&page=1&ts=1665830000 - 末尾追加固定盐值
secret=demo123 - 做 MD5,得到
sign
现实中往往会更复杂,但分析路径是一样的。
实战代码(可运行)
1. 浏览器中 Hook 请求链路
先在控制台里注入 Hook,观察请求从哪里发出、sign 在哪里出现。
(function () {
const originalFetch = window.fetch;
window.fetch = async function (...args) {
const [url, config] = args;
console.log('[fetch url]', url);
console.log('[fetch config]', config);
if (config && config.headers) {
console.log('[fetch headers]', config.headers);
}
const resp = await originalFetch.apply(this, args);
return resp;
};
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._method = method;
this._url = url;
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
if (!this._headers) this._headers = {};
this._headers[key] = value;
return originalSetHeader.call(this, key, value);
};
XMLHttpRequest.prototype.send = function (body) {
console.log('[xhr]', this._method, this._url);
console.log('[xhr headers]', this._headers || {});
console.log('[xhr body]', body);
return originalSend.call(this, body);
};
console.log('hook installed');
})();
这段代码的作用很直接:
- 抓
fetch - 抓 XHR
- 看最终 URL、Header、Body
如果你已经在 Network 中确认了目标请求,这一步就能帮助你判断:
sign 是在请求创建前就存在,还是在发送前临时加进去。
2. 在 Node.js 中复现签名
下面给出一个可以直接运行的签名示例。
const crypto = require('crypto');
function buildSign(params, secret) {
const filtered = Object.keys(params)
.filter((key) => params[key] !== undefined && params[key] !== null && params[key] !== '')
.sort()
.map((key) => `${key}=${params[key]}`)
.join('&');
const plainText = `${filtered}&secret=${secret}`;
const sign = crypto
.createHash('md5')
.update(plainText, 'utf8')
.digest('hex');
return {
plainText,
sign,
};
}
const params = {
page: 1,
keyword: 'phone',
ts: 1665830000,
};
const secret = 'demo123';
const result = buildSign(params, secret);
console.log('plainText:', result.plainText);
console.log('sign:', result.sign);
运行方式:
node sign.js
如果你已经在浏览器里拿到了签名前原文,就可以对照:
- 原文是否一致
- 哈希结果是否一致
3. 用 Python 重放请求
签名常常在 Node.js 里算,但请求批量化更适合用 Python。
import hashlib
import time
import requests
def build_sign(params, secret):
filtered = {k: v for k, v in params.items() if v not in (None, '')}
plain = '&'.join(f'{k}={filtered[k]}' for k in sorted(filtered.keys()))
plain = f'{plain}&secret={secret}'
sign = hashlib.md5(plain.encode('utf-8')).hexdigest()
return plain, sign
def main():
params = {
'page': 1,
'keyword': 'phone',
'ts': int(time.time())
}
secret = 'demo123'
plain, sign = build_sign(params, secret)
print('plain:', plain)
print('sign:', sign)
query = params.copy()
query['sign'] = sign
url = 'https://httpbin.org/get'
resp = requests.get(url, params=query, timeout=10)
print(resp.status_code)
print(resp.text[:500])
if __name__ == '__main__':
main()
这段代码主要演示两个点:
- 签名逻辑如何在请求前拼接
- 如何把签名插回请求中做重放验证
逐步验证清单
做签名复现时,我建议你按下面顺序验证,能少走很多弯路。
清单 1:字段层验证
- 字段名完全一致
- 字段值类型一致(字符串 / 数字)
- 字段顺序一致
- 空值过滤规则一致
- 时间戳单位一致
清单 2:编码层验证
- 是否使用 UTF-8
- 中文是否先 URL 编码
- 空格是否变成
%20或+ - JSON stringify 是否去空格
- Unicode 转义是否一致
清单 3:算法层验证
- MD5 / SHA1 / SHA256 是否判断正确
- 输出是 hex / base64 / 大写 / 小写
- 是否截断
- 是否二次哈希
- 是否有 HMAC key
清单 4:环境层验证
- 是否依赖
window - 是否依赖
document.cookie - 是否依赖
localStorage - 是否依赖 Canvas / WebGL 指纹
- 是否依赖浏览器时区、语言、UA
更复杂一点:签名不只是哈希
现实站点里,签名常见会发展成这种结构:
- 先收集业务参数
- 再读取 Cookie / token
- 再加上随机数和时间戳
- 再做排序或序列化
- 再经过加密函数
- 结果写到 Header
- 服务端还会校验请求频率、指纹一致性
可以抽象为下面这个状态过程:
stateDiagram-v2
[*] --> 收集业务参数
收集业务参数 --> 注入环境参数
注入环境参数 --> 生成签名原文
生成签名原文 --> 执行哈希或加密
执行哈希或加密 --> 写入请求
写入请求 --> 服务端校验
服务端校验 --> [*]
这也是为什么中级实战不能只盯着“某个加密函数”。
常见坑与排查
1. 看到 sign 就以为它是唯一校验项
有些接口表面上校验 sign,实际上还会同时校验:
timestampnoncetrace-id- Cookie 中的会话值
- 请求头中的设备标识
排查建议:
- 先固定除 sign 外的所有参数,观察单变量变化
- 再逐个删字段测试哪个会导致失败
2. 忽略“最终请求”与“源码逻辑”不一致
源码里你看到的是一套参数,但真正发出去时,可能被拦截器改过。
例如:
- Query 被追加公共参数
- Header 被统一注入 token
- Body 被重新序列化
- 时间戳在发送前才生成
排查建议:
- 以 Network 中的最终请求为准
- 在
fetch/xhr.send处断点看最终值
3. 哈希结果对不上,其实是拼接原文错了
这是最高频的坑。常见错法:
- 排序规则错
- 漏掉某个字段
1和'1'混用- 参数值含空格或换行
- 中文编码方式不同
- JSON key 顺序变化
排查建议:
把注意力放到“签名前原文”上,而不是只盯最终 sign。
只要原文一致,算法大概率就对了。
4. 浏览器能算,Node.js 算不出来
这通常意味着签名逻辑依赖浏览器环境。
典型依赖包括:
window.navigatordocument.cookieatob/btoacanvas.toDataURL()crypto.subtle- DOM 事件时间序列
排查建议:
- 先在浏览器控制台直接调用目标函数
- 再一点点迁移到 Node.js
- 缺什么环境,就 mock 什么环境
- 能最小化就不要全量移植
5. 过度依赖格式化后的代码结构
很多混淆代码格式化后看着“像能读懂”,但变量名没意义,容易让人误判。
排查建议:
- 先看数据流,不急着看控制流
- 先确认入参和出参,再看函数内部
- 多用断点和运行时打印,少靠静态猜
安全/性能最佳实践
这一节不只是给“做逆向”的人,也是给“做接口安全”的人一点反向视角。
1. 最小化分析范围
不要试图一次看完整站点 JS。
高效做法是:
- 只围绕一个目标请求
- 只追一个签名参数
- 只抽一条最小链路
这样你能更快形成闭环验证。
2. 建立“样本对照组”
至少准备 3 组请求样本:
- 相同参数、不同时间
- 相同时间、不同业务参数
- 相同业务、不同会话
有了对照组,你更容易判断:
- 哪些字段参与签名
- 哪些字段只是随请求变化
- 哪些字段和用户会话绑定
3. 自动化时控制请求频率
真实系统往往不只校验 sign,还会做风控:
- IP 限流
- 会话频率异常
- Header 指纹异常
- 行为轨迹不自然
因此自动化测试时要注意:
- 控制并发和间隔
- 保持 Header 一致性
- 会话与指纹不要频繁切换
- 合法测试要提前获授权
4. 抽离签名模块时保留可观测性
我建议把复现代码写成这种形式:
- 能输出签名原文
- 能输出参与字段
- 能输出最终 sign
- 能单独校验每一步
这样后面站点升级时,你会非常容易定位是哪一步变了。
例如:
function debugBuildSign(params, secret) {
const keys = Object.keys(params).sort();
const pairs = keys.map((k) => `${k}=${params[k]}`);
const plain = `${pairs.join('&')}&secret=${secret}`;
const sign = require('crypto').createHash('md5').update(plain).digest('hex');
return {
keys,
pairs,
plain,
sign,
};
}
5. 对防守方的建议
如果你是接口安全或前端安全侧,单纯把算法藏在前端并不稳妥。更合理的是:
- 不把核心密钥完全暴露在前端
- 让服务端参与校验挑战
- 结合时效、会话、设备、行为综合判断
- 对重放攻击和批量请求做限速与告警
前端签名能提高滥用成本,但不能替代服务端安全策略。
一个中级读者值得养成的思维方式
到这个阶段,我很建议你把“逆向”理解成三件事:
1. 先找边界
不是所有代码都需要懂。
你真正需要的是:
- 输入是什么
- 输出是什么
- 中间最关键的变换在哪
2. 先还原流程,再还原细节
先知道:
- 请求怎么发
- sign 什么时候生成
- 原文怎么拼
再去看具体算法。
这比一开始就冲进混淆代码高效很多。
3. 最终目标是“稳定复现”,不是“看懂全部源码”
很多项目代码量巨大,完全看懂不现实。
但你只要能做到:
- 找准请求链路
- 还原签名原文
- 稳定复现参数
就已经完成了大部分实战目标。
总结
这篇文章想传递的核心只有一句话:
Web 逆向里,签名参数复现的关键,不是先破算法,而是先理清请求链路和数据流。
一个更稳的中级方法论是:
- 在 Network 中锁定目标请求
- 沿 Initiator 和调用栈回溯到请求封装层
- 找到 sign 的最终写入点
- 确认签名前原文,而不是只盯哈希函数
- 抽离最小可运行逻辑到 Node.js / Python
- 用样本对照做持续验证
如果你已经能熟练完成这些步骤,那么后面遇到更复杂的场景——比如混淆、环境依赖、动态 token、多段签名——也只是工作量更大,而不是思路完全失效。
最后给一个很实用的建议:
每次分析都保存“请求样本 + 签名原文 + 复现代码 + 验证结果”四件套。
这会让你的逆向过程从“碰运气”变成“可复盘、可维护”的工程化流程。