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

《Web逆向实战:从XHR抓包到关键参数还原,系统分析前端加密接口的定位与复现》

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

背景与问题

做 Web 逆向时,最常见的场景不是“页面看不懂”,而是“接口明明看到了,但请求就是复现不出来”。
尤其是带前端加密的网站,XHR 请求里通常会出现这些典型特征:

  • signtokenciphertnonce 这类动态参数
  • 请求体字段经过 Base64AESRSAHexURL Encode 多层包装
  • 请求头里夹带设备指纹、时间戳、校验串
  • 同一个接口,复制 curl 后重放失败,提示签名错误、参数非法、请求过期

我自己第一次碰这类接口时,最大的误区就是:盯着请求本身看太久,却没有回到“这个参数是谁生成的”
所以这篇文章不只是讲抓包,而是从“架构视角”把整个定位链路梳理清楚:从 XHR 抓包,到加密函数追踪,再到关键参数还原,最终实现接口复现。

本文默认读者已经会基本的浏览器开发者工具、JavaScript 调试和 Python 请求发送。


背心问题的本质:不是“接口难”,而是“参数生成链路被隐藏”

先把问题抽象一下。一个前端加密接口,本质上通常包含三层:

  1. 业务参数层:真正要提交的数据,比如关键词、页码、用户动作
  2. 变换层:序列化、排序、拼接、摘要、加密
  3. 传输层:XHR/fetch 发请求,附加 headers、cookies、trace 信息

真正让接口无法复现的,往往不是网络层,而是中间的变换层

flowchart LR
A[页面操作] --> B[业务参数组装]
B --> C[前端变换/签名]
C --> D[XHR或fetch发送]
D --> E[服务端验签/解密]
E --> F[业务响应]

我们抓到的请求,只是链路末端的产物。
要复现,就得反推回去:

  • 哪些字段是固定的?
  • 哪些字段是动态生成的?
  • 动态字段依赖时间、随机数、cookie,还是隐藏常量?
  • 加密前的原文是什么?
  • 加密算法在浏览器里跑,还是 WebAssembly 里跑?

核心原理

1. 从网络请求倒推参数生成链

面对一个失败的复现请求,我一般按下面顺序拆:

第一步:识别“业务字段”和“安全字段”

例如一个常见请求体可能长这样:

{
  "q": "laptop",
  "page": 1,
  "t": "1700000123",
  "nonce": "9f2c7f6d",
  "sign": "b8e1c5..."
}

这里:

  • qpage 多半是业务字段
  • tnoncesign 多半是安全字段

经验上,以下字段值得优先怀疑:

  • 明显短而随机:noncesalt
  • 时间相关:ttstimestamp
  • 长十六进制/长 Base64:signtokendata
  • 请求头自定义字段:x-signx-tokenx-trace

第二步:验证字段是否参与签名

一个简单方法是:

  • 抓一组成功请求
  • 修改业务参数后重放
  • 看服务端报错是“业务错误”还是“签名错误”

如果改了 q 后就签名失败,说明 q 在签名串里。
如果只改 page 还能成功,说明可能没参与,或者参与方式特殊。

第三步:用“全局搜索 + 断点”找参数生成点

定位顺序我通常这么走:

  1. 在 DevTools 的 Network 里找到 XHR/fetch 请求
  2. 查看 Initiator,找到触发请求的脚本
  3. 全局搜索字段名,比如 sign、接口路径、固定请求头名
  4. 对以下位置打断点:
    • XMLHttpRequest.prototype.send
    • fetch
    • CryptoJS 常见函数
    • atob / btoa
    • JSON.stringify
    • encodeURIComponent

这一步的目标不是马上看懂所有代码,而是先抓住调用栈


2. 常见前端加密模式

前端“加密”很多时候其实不是严格意义上的安全加密,而是接口参数混淆或签名校验。常见模式有:

模式 A:拼接后哈希

sign = md5(path + timestamp + nonce + payload + secret)

特点:

  • sign 往往是 32 位 hex
  • 算法简单,藏的是 secret 和拼接顺序

模式 B:对象排序后签名

keys sort -> k1=v1&k2=v2 -> sha256(...)

特点:

  • 参数顺序敏感
  • undefined、空字符串、数字转字符串都可能影响结果

模式 C:请求体整体加密

data = AES(JSON.stringify(payload), key, iv)

特点:

  • 请求体只有一个密文字段
  • 重点在于找 keyiv、模式(CBC/ECB)和填充方式

模式 D:RSA 包装 AES 密钥

特点:

  • 业务数据 AES 加密
  • AES key 再用 RSA 公钥加密
  • 看起来复杂,但前端一定要拿到加密逻辑,照样能还原流程

模式 E:运行时动态混淆

特点:

  • 变量名全被压扁
  • 字符串表偏移
  • 甚至放进 WebAssembly
  • 这时重点从“阅读源码”转向“截获运行时输入输出”

3. 架构视角:定位策略的取舍

如果把 Web 逆向当成一个工程问题,而不是一次性脚本问题,会更稳。

方案一:纯抓包重放

优点

  • 上手快
  • 适合参数短时有效、校验弱的站点

缺点

  • 一旦签名时效短,就很不稳定
  • 无法规模化
  • 页面版本一更新就失效

方案二:浏览器内补环境调用原函数

优点

  • 复用页面现成逻辑
  • 适合复杂混淆、难读代码

缺点

  • 依赖浏览器环境
  • 自动化部署复杂
  • 性能一般

方案三:脱离浏览器还原算法

优点

  • 可控、可测试、可批量运行
  • 更适合长期维护

缺点

  • 前期分析成本高
  • 一些环境依赖要手工补齐

我的经验是:

  • 临时验证:先用浏览器内调用
  • 稳定复现:再把核心算法迁到 Python/Node
  • 长期维护:把参数生成做成独立模块,和采集逻辑解耦
flowchart TD
A[发现接口] --> B{目标是什么}
B -->|快速验证| C[抓包重放]
B -->|看懂签名| D[浏览器调试]
B -->|长期复用| E[脱离浏览器还原]
C --> F[是否稳定]
F -->|否| D
D --> G[参数生成链清晰]
G --> E
E --> H[形成可维护模块]

实战代码(可运行)

下面我用一个可运行的最小案例演示完整过程。
这个例子不是某真实站点代码,而是把真实分析中最常见的逻辑抽象出来:时间戳 + nonce + JSON 序列化 + SHA256 签名

目标请求格式如下:

{
  "data": {
    "q": "phone",
    "page": 1
  },
  "t": 1700000123,
  "nonce": "ab12cd34",
  "sign": "..."
}

签名规则:

sign = SHA256(JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret)

1. 前端示例代码:模拟页面里的加密逻辑

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>demo</title>
</head>
<body>
  <script>
    async function sha256(text) {
      const enc = new TextEncoder().encode(text);
      const buf = await crypto.subtle.digest("SHA-256", enc);
      return Array.from(new Uint8Array(buf))
        .map(x => x.toString(16).padStart(2, "0"))
        .join("");
    }

    function randomNonce(len = 8) {
      const chars = "abcdef0123456789";
      let s = "";
      for (let i = 0; i < len; i++) {
        s += chars[Math.floor(Math.random() * chars.length)];
      }
      return s;
    }

    async function buildPayload(q, page) {
      const secret = "demo_secret_v1";
      const data = { q, page };
      const t = Math.floor(Date.now() / 1000);
      const nonce = randomNonce();
      const raw = JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret;
      const sign = await sha256(raw);
      return { data, t, nonce, sign };
    }

    async function sendRequest() {
      const payload = await buildPayload("phone", 1);
      console.log("request payload =>", payload);

      const res = await fetch("/api/search", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload)
      });
      console.log(await res.text());
    }

    sendRequest();
  </script>
</body>
</html>

这个例子里,逆向的关键点是:

  • secret
  • JSON.stringify(data) 的格式
  • tnonce
  • 最终 raw 拼接顺序

2. Node.js 复现签名逻辑

如果你已经在前端代码里定位到上述逻辑,就可以先在 Node 里独立复现。

const crypto = require("crypto");

function signPayload(data, t, nonce, secret) {
  const raw = JSON.stringify(data) + "|" + t + "|" + nonce + "|" + secret;
  return crypto.createHash("sha256").update(raw).digest("hex");
}

const data = { q: "phone", page: 1 };
const t = 1700000123;
const nonce = "ab12cd34";
const secret = "demo_secret_v1";

const sign = signPayload(data, t, nonce, secret);
console.log({ data, t, nonce, sign });

运行:

node sign_demo.js

3. Python 复现并发送请求

实际项目里,很多人最后会用 Python 做接口自动化,所以这里给一个可直接运行的版本。

import json
import time
import random
import hashlib
import requests

SECRET = "demo_secret_v1"

def random_nonce(length=8):
    chars = "abcdef0123456789"
    return "".join(random.choice(chars) for _ in range(length))

def calc_sign(data, t, nonce, secret=SECRET):
    raw = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + "|" + str(t) + "|" + nonce + "|" + secret
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

def build_payload(q, page):
    data = {"q": q, "page": page}
    t = int(time.time())
    nonce = random_nonce()
    sign = calc_sign(data, t, nonce)
    return {
        "data": data,
        "t": t,
        "nonce": nonce,
        "sign": sign
    }

if __name__ == "__main__":
    payload = build_payload("phone", 1)
    print("payload =", payload)

    # 示例地址,替换成真实目标
    url = "https://example.com/api/search"
    headers = {
        "Content-Type": "application/json",
        "User-Agent": "Mozilla/5.0"
    }

    # 演示请求发送;真实环境按需启用
    # resp = requests.post(url, headers=headers, json=payload, timeout=10)
    # print(resp.status_code, resp.text)

这里有一个非常关键的细节:
我在 json.dumps 里用了 separators=(",", ":"),这是为了尽量贴近浏览器 JSON.stringify 的输出。
很多签名失败,问题就出在空格、字段顺序、编码格式上。


4. 如何在浏览器里快速截获签名原文

当代码混淆严重时,我经常不急着“看懂”,而是先截输入输出
下面这段脚本可以在控制台里临时 hook fetch,观察请求体。

(function () {
  const rawFetch = window.fetch;
  window.fetch = async function (...args) {
    const [url, options] = args;
    console.log("fetch url:", url);
    if (options && options.body) {
      console.log("fetch body:", options.body);
    }
    const res = await rawFetch.apply(this, args);
    return res;
  };
})();

如果站点使用 XMLHttpRequest,可以这样 hook:

(function () {
  const rawOpen = XMLHttpRequest.prototype.open;
  const rawSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method, url) {
    this._method = method;
    this._url = url;
    return rawOpen.apply(this, arguments);
  };

  XMLHttpRequest.prototype.send = function (body) {
    console.log("xhr:", this._method, this._url);
    console.log("xhr body:", body);
    return rawSend.apply(this, arguments);
  };
})();

进一步,如果你怀疑使用了 CryptoJS.SHA256 之类的函数,也可以做更细粒度的 hook。
思路是一样的:记录传入参数和返回值,比硬啃混淆代码高效得多。


从抓包到还原:一条可执行的定位路径

这一段我按实战顺序总结成流程,适合你拿去直接套。

sequenceDiagram
    participant U as 用户操作
    participant B as 浏览器页面
    participant S as 签名函数
    participant X as XHR/fetch
    participant R as 服务端

    U->>B: 点击搜索
    B->>S: 组装 data/t/nonce
    S->>S: 计算 sign / 加密 data
    S->>X: 返回最终请求参数
    X->>R: 发送请求
    R->>R: 验签/解密
    R-->>X: 返回结果
    X-->>B: 页面渲染

建议按下面 7 步走:

  1. 在 Network 中锁定目标请求
  2. 记下请求方法、路径、headers、body 格式
  3. 区分业务字段和动态安全字段
  4. 从 Initiator 或全局搜索进入源码
  5. 在发送点附近打断点,看调用栈
  6. 找到最终签名输入原文
  7. 在 Node/Python 中最小化复现,逐步对齐

这里最重要的一句话是:
先对齐“签名前原文”,再对齐“算法实现”。

因为很多时候算法不复杂,复杂的是原文构造过程,比如:

  • 字段是否排序
  • 数字是否转字符串
  • null 是否参与拼接
  • URL 是否先编码再签名
  • body 是对象还是字符串

常见坑与排查

这部分我尽量写得接地气一点,很多问题我自己都踩过。

1. 明明算法对了,签名还是错

优先检查这几项:

  • JSON.stringify 输出是否一致
  • 字段顺序是否一致
  • 是否少了某个隐藏常量
  • 时间戳单位是秒还是毫秒
  • nonce 是否长度固定
  • 十六进制输出大小写是否敏感

典型排查办法

把浏览器内生成的原文和你本地生成的原文都打印出来,一字符一字符比。

print(repr(raw))

很多时候差的不是算法,而是一个空格、一个分隔符、一个大小写。


2. 抓包里看到的是密文,找不到明文

这通常说明你观察得太晚了。
请求发出去时已经加密完成,所以应该往前追:

  • send 前断点
  • 在加密函数入口断点
  • JSON.stringifyencryptdigest 一类函数上断点

如果站点启用了 source map,直接看源码会轻松很多;没有的话就靠调用栈定位。


3. 请求复现偶发成功、偶发失败

这种情况大概率与“时效性”有关:

  • 时间戳过期
  • nonce 重复
  • token 与 cookie 绑定
  • 签名依赖某个会变化的 header

建议做三件事:

  1. 抓多组请求,对比变化字段
  2. 把 cookie、headers、payload 一起存档
  3. 观察成功请求的时间窗口

4. Python 复现总是和浏览器不同

我见过最常见的原因有两个:

原因 A:序列化不一致

json.dumps(data)

默认输出可能包含空格,而前端 JSON.stringify 没有。
应改成:

json.dumps(data, separators=(",", ":"), ensure_ascii=False)

原因 B:字符编码不一致

浏览器一般是 UTF-8。
如果你本地拼接或转码时混入了其他编码,摘要结果一定不一样。


5. 遇到混淆、压缩、甚至 WebAssembly 怎么办

不要一开始就想着完整反编译。
中级阶段最有效的策略仍然是:

  • 找输入输出
  • 找调用链
  • 找边界函数
  • 在运行时截获参数

尤其是 WebAssembly 场景,很多人会被“看不懂”吓住。
其实如果最终请求还要经过 JS 层,仍然可以从 JS-WASM 边界截获参数。

flowchart LR
A[业务数据] --> B[JS层预处理]
B --> C[WASM/混淆函数]
C --> D[返回sign或密文]
D --> E[XHR/fetch发送]

安全/性能最佳实践

虽然本文重点是逆向分析,但从架构角度,也有必要说清楚边界:
前端加密不是万能防护,更多是增加门槛,而不是建立真正的信任。

1. 安全上的正确认知

前端密钥不可作为绝对秘密

只要密钥在前端运行环境里可达,就存在被提取的可能。
因此:

  • 前端签名适合作为“防滥用”措施
  • 不适合作为高价值安全边界的唯一手段

服务端必须二次校验

至少应校验:

  • 时间戳有效期
  • nonce 去重
  • 用户态 token
  • 频率限制
  • 风控策略

不要把真正敏感逻辑完全押在前端

比如:

  • 权限判定
  • 核心价格计算
  • 高价值数据解密

这些必须由服务端掌控。


2. 逆向复现代码的工程化建议

如果你是做内部测试、协议分析或自动化验证,建议把代码分层:

  • client.py:发请求
  • sign.py:参数生成
  • models.py:数据结构
  • tests/:签名回归测试

一个简单的模块划分示例

# sign.py
import json
import hashlib

def calc_sign(data, t, nonce, secret):
    raw = json.dumps(data, separators=(",", ":"), ensure_ascii=False) + "|" + str(t) + "|" + nonce + "|" + secret
    return hashlib.sha256(raw.encode()).hexdigest()
# client.py
import time
import random
import requests
from sign import calc_sign

SECRET = "demo_secret_v1"

def random_nonce(length=8):
    chars = "abcdef0123456789"
    return "".join(random.choice(chars) for _ in range(length))

def search(q, page):
    data = {"q": q, "page": page}
    t = int(time.time())
    nonce = random_nonce()
    sign = calc_sign(data, t, nonce, SECRET)

    payload = {
        "data": data,
        "t": t,
        "nonce": nonce,
        "sign": sign
    }
    return requests.post("https://example.com/api/search", json=payload, timeout=10)

这样做的好处是:
页面逻辑更新时,你只需要改签名模块,不会牵一发动全身。


3. 性能上的取舍

本地复现优于浏览器驱动复现

如果只是要批量调用接口,能脱离浏览器就尽量脱离:

  • 资源占用更低
  • 并发更高
  • 稳定性更好

但复杂环境不要过早“纯净化”

如果接口依赖:

  • 浏览器指纹
  • Canvas/WebGL
  • 特定 window 属性
  • 某些运行时对象

那就别一上来追求纯 Python。
先在浏览器内把逻辑跑通,再决定哪些部分值得迁移。


方案对比与取舍分析

从架构角度看,接口复现并不是只有“能不能做到”,还涉及维护成本。

方案适用场景优点缺点
直接抓包重放临时验证快速、简单稳定性差
浏览器 hook 调原函数混淆重、逻辑复杂成功率高部署重、性能一般
Node/Python 还原算法长期维护可测试、易集成前期分析成本高
混合方案中大型项目灵活、渐进演化需要更好工程管理

如果团队需要长期维护某类协议,我建议采用混合方案

  1. 用浏览器 hook 快速验证
  2. 提取稳定参数生成链
  3. 将签名逻辑迁移到独立模块
  4. 建立回归测试,防止页面升级后静默失效

总结

Web 逆向里最难的,通常不是“抓不到请求”,而是“抓到了却复现不了”。
这类前端加密接口的破题关键,可以浓缩成三句话:

  1. 先区分业务参数和安全参数
  2. 先找到签名前原文,再分析算法
  3. 先跑通,再工程化重构

如果你正在实战中卡住,我建议按这个最小清单排查:

  • 目标请求是否锁定正确
  • sign/t/nonce/token 哪些是动态的
  • 签名原文是否已完整拿到
  • 序列化、排序、编码是否对齐
  • 是否存在时效、cookie、header 绑定
  • 是否该从浏览器内调用过渡到本地还原

最后提醒一个边界:
本文讨论的是接口分析、调试与协议理解的方法论。实际使用时,应确保行为符合法律、授权与目标系统规则。
从技术上看,前端加密更像一道门槛;从工程上看,真正可持续的能力,是你能否把这道门槛拆解成可定位、可验证、可维护的参数生成链。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口响应抖动与内存飙升-435》