跳转到内容
123xiao | 无名键客

《Web逆向实战:从请求链路分析到关键参数还原的中级方法论》

字数: 0 阅读时长: 1 分钟

Web逆向实战:从请求链路分析到关键参数还原的中级方法论

很多人学 Web 逆向,会卡在一个很典型的阶段:

  • 抓包会了;
  • 请求也能看到;
  • 但一到关键参数,比如 signtokennoncetimestampfingerprint,就开始发懵;
  • 代码一堆混淆,断点一下全是匿名函数,最后怀疑人生。

如果你也在这个阶段,这篇文章就是写给你的。它不是“某站点一把梭”的题解,而是我更推荐的一种中级实战方法论先梳理请求链路,再定位参数生成,再做最小还原验证
这套方法的好处是,面对不同站点时不容易完全失效,迁移性比“背脚本”强很多。

先说明边界:本文内容仅用于授权测试、接口联调、安全研究与学习,请勿用于未授权的数据抓取或绕过访问控制。


背景与问题

在真实业务站点里,前端请求参数很少是“直接写死”的。更常见的是:

  1. 页面初始化时下发某些配置;
  2. 浏览器环境参与生成指纹;
  3. 请求发送前经过一层或多层封装;
  4. 最终才生成关键参数并发出请求。

也就是说,你抓到的一个请求,往往只是结果,不是原因

举个常见例子,请求里有这样的参数:

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-beautify
    • webpack-bundle-analyzer 思路类插件
    • source-map-explorer(如果有 sourcemap)

核心原理

中级 Web 逆向,核心不是“解算法”,而是解决下面这三个问题:

  1. 请求是谁发起的?
  2. 参数在哪里被加工?
  3. 加工所依赖的输入从哪里来?

你可以把它看成一个“从结果倒推源头”的过程。

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.send
  • axios.create(...)
  • interceptors.request.use(...)
  • CryptoJS
  • window.atob/btoa
  • JSON.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 中建议这样做:

  1. 打开 Network,找到目标请求;
  2. Initiator,确认由哪个脚本触发;
  3. 在 Sources 中对请求 URL 片段搜索;
  4. fetch(axiosinterceptors.request.use
  5. 如果 sign 名字能搜到最好,搜不到就搜:
    • 请求 path
    • 固定 header
    • body 里的业务字段
  6. 在请求发送前关键位置打断点,看参数变化。

如果站点混淆较重,我通常不会先从“加密函数”开始,而是优先找:

  • 请求封装入口
  • 请求拦截器
  • 最终 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 秒。
你本地偶尔成功,不代表逻辑对了,可能只是运气好。

排查建议: 固定 tsnonce 做重复实验,消掉随机因素。


坑 4:请求体和签名体不是同一个东西

有些站点是:

  • 实际发送的 body 是加密后的;
  • 但签名时用的是加密前的原文。

也有反过来的:

  • 发送明文;
  • 但签名的是压缩/编码后的字符串。

排查建议: 在“最终请求发送前”打断点,看 config 中到底存了几份 body。


坑 5:密钥是动态下发的

你看到代码里写着 secret = t(0x1a3),以为是常量;结果它其实来自:

  • 首屏接口
  • 内嵌 script
  • cookie 解码
  • 动态 JS 文件

排查建议: 给密钥变量打读写断点,追它第一次赋值的位置。


坑 6:环境检测触发了降级逻辑

一些站点会检测:

  • DevTools 是否打开
  • webdriver 痕迹
  • headless 特征
  • navigator.plugins
  • canvas/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逆向的中级突破,不在于多会几种加密算法,而在于能从请求链路出发,稳定定位参数生成位置,并做最小可验证还原。

你可以记住这套主线:

  1. 先抓请求,确认触发时机
  2. 再看 Initiator,找到调用栈
  3. 定位请求封装层与拦截器
  4. 观察参数在发送前如何变化
  5. 识别签名输入、格式与依赖
  6. 用最小脚本做固定输入还原
  7. 逐项对比浏览器结果,收敛误差

如果你现在正卡在某个 signtoken 上,我给你的最可执行建议是:

  • 不要先抠算法,先找链路;
  • 不要只看结果,打印原始输入串;
  • 不要一次性自动化,先做最小验证;
  • 不要忽视序列化、时间戳、环境依赖这些“小问题”。

很多时候,真正拦住你的不是“加密太强”,而是分析顺序不对。

当你把“请求链路分析 → 参数生成定位 → 最小还原验证”这条线走顺了,面对新站点时,你会明显感觉:
虽然细节不同,但思路终于是自己的了。


分享到:

上一篇
《Java 开发踩坑实战:排查与修复线程池误用导致的内存暴涨和请求堆积》
下一篇
《分布式架构中基于一致性哈希的服务路由与节点扩缩容实战》