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

《Web逆向实战:基于浏览器开发者工具与 Hook 技术定位前端签名参数生成逻辑》

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

背景与问题

做 Web 接口分析时,最常见也最“劝退”的一步,就是接口里多了几个一眼看不懂的参数,比如:

  • sign
  • token
  • nonce
  • timestamp
  • x-s
  • sig

你在 Network 面板里明明能看到请求已经发出,也能拿到完整 URL、Header、Body,但一旦离开浏览器环境自己复现,请求就直接失败。原因通常不是接口路径错了,而是前端在发送请求前,动态生成了一组签名参数

这类场景的难点在于:

  1. 签名逻辑常常被打散在多个 JS 文件里
  2. 代码可能经过压缩、混淆,变量名几乎不可读
  3. 签名生成可能依赖浏览器环境,例如:
    • window
    • document
    • navigator
    • localStorage
    • canvas
  4. 有些站点会把加密逻辑塞进:
    • XMLHttpRequest.send
    • fetch
    • Axios 请求拦截器
    • Webpack 模块内部函数
    • eval/new Function 动态执行代码里

所以这篇文章不讲“大而空”的逆向理论,我直接带你走一条实战可落地的定位路径
先从浏览器开发者工具拿到请求现场,再用 Hook 技术反向逼近签名生成点。

如果你之前的习惯是上来就全局搜 sign,那这篇文章的目标就是帮你把“猜”变成“定位”。


前置知识与环境准备

建议先具备以下基础:

  • 会使用 Chrome DevTools
  • 了解基本 HTTP 请求结构
  • 看得懂 JavaScript 基础语法
  • 知道 fetchXMLHttpRequest、Axios 的区别

实验环境建议:

  • Chrome 浏览器
  • 一个带签名参数的目标页面
  • 本地可注入调试脚本的方式:
    • 控制台直接粘贴代码
    • Snippets
    • Tampermonkey
    • 本地代理替换 JS

核心原理

要定位签名生成逻辑,核心不是“猜算法”,而是先回答下面三个问题:

  1. 签名在什么时候被拼进请求?
  2. 签名依赖哪些原始输入?
  3. 真正参与运算的函数在哪里?

一个高效的方法是从“结果”倒推“过程”:

  • 已知结果:Network 里的请求参数
  • 观察时机:请求发起前的 JS 调用链
  • 插入 Hook:拦截关键 API
  • 回溯调用栈:找到签名构造函数

从请求生命周期理解定位思路

flowchart TD
    A[页面触发业务动作] --> B[组装业务参数]
    B --> C[生成时间戳/随机数]
    C --> D[调用签名函数]
    D --> E[拼接 Header 或 Query]
    E --> F[fetch/XHR 发起请求]
    F --> G[服务端验签]

很多人卡在 D,但真正好定位的切入点通常是 E 和 F。因为:

  • D 可能被混淆
  • E/F 必须接触明文请求参数
  • 在 E/F 处 Hook,能拿到最终 payload 和调用栈

常用定位入口

我一般按下面顺序来排:

  1. Network 面板看请求
    • 参数放在 Query、Body 还是 Header
  2. 全局搜索关键字段
    • sign
    • 搜请求路径片段
    • 搜自定义 Header 名
  3. Hook 网络发送层
    • fetch
    • XMLHttpRequest.open/send
    • Axios 拦截器
  4. Hook 可疑加密原语
    • JSON.stringify
    • btoa
    • atob
    • CryptoJS.MD5
    • CryptoJS.SHA256
    • window.crypto.subtle.digest
  5. 结合调用栈回溯
    • console.trace()
    • debugger
    • DevTools Call Stack

一套实战定位流程

这一部分按“我真的在页面上操作”的方式来讲。

第 1 步:先在 Network 里确认签名落点

打开 DevTools,切到 Network,重新操作页面,让目标请求发出。重点看:

  • 请求 URL 中有没有 sign
  • Form Data / Request Payload 里有没有签名字段
  • Request Headers 里是否存在自定义签名头

例如你看到:

POST /api/user/info
Headers:
  x-sign: 8f4a0d...
  x-ts: 1735200000
Body:
  {"uid":12345}

这时要先记下:

  • 签名字段名:x-sign
  • 关联字段:x-ts
  • 接口路径:/api/user/info

这一步看似简单,但很关键。因为后面的 Hook,必须围绕这些特征展开。


第 2 步:全局搜索“稳定特征”,不要只搜 sign

Sources -> Search 里优先搜索这些内容:

  • 请求路径,如 /api/user/info
  • Header 名,如 x-sign
  • 参数名,如 x-ts
  • 固定字符串前缀
  • 某些业务字段名

为什么不只搜 sign?因为实际项目里:

  • 变量可能叫 s
  • Header 可能在对象字面量里动态赋值
  • 字段名可能被字符串拼接出来

我自己踩过一个坑:站点把 header key 写成了:

headers[prefix + "-sign"] = value;

直接搜 x-sign 根本搜不到,最后是通过 Hook fetch 才定位到。


第 3 步:Hook fetch,抓最终请求参数和调用栈

现代站点大量使用 fetch。先上一个最实用的 Hook:

(() => {
  const rawFetch = window.fetch;
  window.fetch = async function(...args) {
    const [input, init] = args;

    console.group("=== fetch hook ===");
    console.log("input:", input);
    console.log("init:", init);
    console.trace("fetch call stack");
    console.groupEnd();

    debugger; // 需要时打开,直接中断到发请求前
    return rawFetch.apply(this, args);
  };

  console.log("[hook] fetch installed");
})();

看什么

执行后重新触发请求,你通常能看到:

  • URL
  • method
  • headers
  • body
  • 调用栈

如果签名在 headersbody 里已经出现,那么你已经拿到了最关键的信息:
是谁在调用 fetch 的前一刻,完成了签名拼装。

第 4 步:Hook XHR,兼容老站点和部分框架封装

有些站点不用 fetch,而是 XMLHttpRequest。继续补一个:

(() => {
  const rawOpen = XMLHttpRequest.prototype.open;
  const rawSend = XMLHttpRequest.prototype.send;
  const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
    this._hookMethod = method;
    this._hookUrl = url;
    return rawOpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function(key, value) {
    if (!this._hookHeaders) this._hookHeaders = {};
    this._hookHeaders[key] = value;
    return rawSetRequestHeader.call(this, key, value);
  };

  XMLHttpRequest.prototype.send = function(body) {
    console.group("=== xhr hook ===");
    console.log("method:", this._hookMethod);
    console.log("url:", this._hookUrl);
    console.log("headers:", this._hookHeaders);
    console.log("body:", body);
    console.trace("xhr send stack");
    console.groupEnd();

    debugger;
    return rawSend.call(this, body);
  };

  console.log("[hook] xhr installed");
})();

这样无论目标站点走哪种发送方式,你基本都能兜住。


第 5 步:发现签名字段后,反向 Hook 可疑加密函数

如果你已经知道签名长得像 MD5 / SHA / Base64,就可以顺着特征继续逼近。

Hook btoa

(() => {
  const raw = window.btoa;
  window.btoa = function(str) {
    console.group("=== btoa hook ===");
    console.log("input:", str);
    console.trace("btoa stack");
    console.groupEnd();
    debugger;
    return raw.call(this, str);
  };
})();

Hook JSON.stringify

很多签名逻辑会先把对象序列化:

(() => {
  const raw = JSON.stringify;
  JSON.stringify = function(...args) {
    console.group("=== JSON.stringify hook ===");
    console.log("args:", args);
    console.trace("stringify stack");
    console.groupEnd();
    return raw.apply(this, args);
  };
})();

Hook Web Crypto

(() => {
  if (!window.crypto || !window.crypto.subtle) return;
  const raw = window.crypto.subtle.digest;
  window.crypto.subtle.digest = function(...args) {
    console.group("=== subtle.digest hook ===");
    console.log("algorithm:", args[0]);
    console.log("data:", args[1]);
    console.trace("digest stack");
    console.groupEnd();
    debugger;
    return raw.apply(this, args);
  };
})();

如果页面使用第三方库,比如 CryptoJS.MD5,也可以这样 Hook:

(() => {
  if (!window.CryptoJS || !window.CryptoJS.MD5) {
    console.log("CryptoJS.MD5 not found");
    return;
  }

  const raw = window.CryptoJS.MD5;
  window.CryptoJS.MD5 = function(...args) {
    console.group("=== CryptoJS.MD5 hook ===");
    console.log("args:", args);
    console.trace("md5 stack");
    console.groupEnd();
    debugger;
    return raw.apply(this, args);
  };
})();

从 Hook 到定位源码:一条完整路径

下面用一个简化案例串起来。

假设请求长这样:

POST /api/order/list
Headers:
  x-ts: 1735200000
  x-sign: c0ffee123456
Body:
  {"page":1,"size":20}

我们先 Hook fetch,看到最终 headers:

{
  "x-ts": "1735200000",
  "x-sign": "c0ffee123456"
}

调用栈里出现:

  • request.js:formatted:120
  • api.js:formatted:45
  • order.js:formatted:88

这时进入 request.js 断点位置,往上一两行通常能看到类似:

const ts = Date.now().toString();
const sign = makeSign(url, body, ts);
headers["x-ts"] = ts;
headers["x-sign"] = sign;
return fetch(url, { method, headers, body });

然后继续跟进 makeSign,就可能见到:

function makeSign(url, body, ts) {
  const payload = url + "|" + body + "|" + ts + "|" + secret;
  return md5(payload);
}

到这里,签名原始输入就全部拿到了。

这个过程可以抽象成下面的图:

sequenceDiagram
    participant U as 用户操作
    participant P as 页面业务代码
    participant S as 签名函数
    participant N as fetch/XHR Hook
    participant R as 服务端

    U->>P: 点击查询/翻页
    P->>S: 传入 url/body/timestamp
    S-->>P: 返回 sign
    P->>N: 发起请求并附带 sign
    N-->>P: 输出 headers/body/调用栈
    P->>R: 正式请求

实战代码(可运行)

这一节给一套“可直接粘贴到控制台”的调试工具箱。它的目的不是替代专业调试器,而是帮助你快速筛出关键点。

1. 通用网络 Hook 工具

(function installNetHook() {
  if (window.__netHookInstalled__) {
    console.log("[hook] already installed");
    return;
  }
  window.__netHookInstalled__ = true;

  // fetch hook
  if (window.fetch) {
    const rawFetch = window.fetch;
    window.fetch = async function(...args) {
      const [input, init] = args;
      console.group("%c[fetch]", "color: green;");
      console.log("url/input:", input);
      console.log("init:", init);

      try {
        if (init && init.headers) {
          console.log("headers:", init.headers);
        }
        if (init && init.body) {
          console.log("body:", init.body);
        }
      } catch (e) {
        console.warn("read fetch init failed:", e);
      }

      console.trace("fetch trace");
      console.groupEnd();
      return rawFetch.apply(this, args);
    };
  }

  // xhr hook
  const rawOpen = XMLHttpRequest.prototype.open;
  const rawSend = XMLHttpRequest.prototype.send;
  const rawSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;

  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
    this.__method = method;
    this.__url = url;
    this.__headers = {};
    return rawOpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.setRequestHeader = function(k, v) {
    this.__headers[k] = v;
    return rawSetRequestHeader.call(this, k, v);
  };

  XMLHttpRequest.prototype.send = function(body) {
    console.group("%c[xhr]", "color: blue;");
    console.log("method:", this.__method);
    console.log("url:", this.__url);
    console.log("headers:", this.__headers);
    console.log("body:", body);
    console.trace("xhr trace");
    console.groupEnd();
    return rawSend.call(this, body);
  };

  console.log("[hook] net hook installed");
})();

2. 签名特征过滤器

如果页面请求很多,日志会刷屏。可以加一个过滤器,只盯某个接口:

(function installFilteredFetchHook() {
  const keyword = "/api/order/list";
  const rawFetch = window.fetch;

  window.fetch = async function(...args) {
    const [input, init] = args;
    const url = typeof input === "string" ? input : (input && input.url) || "";

    if (url.includes(keyword)) {
      console.group("%c[target fetch]", "color: red;");
      console.log("url:", url);
      console.log("init:", init);
      console.trace("target trace");
      console.groupEnd();
      debugger;
    }

    return rawFetch.apply(this, args);
  };

  console.log("[hook] filtered fetch installed:", keyword);
})();

3. 一个本地可运行的签名演示页

如果你想先在本地理解整个思路,可以用下面这段 HTML。它模拟“前端生成签名再请求”的过程。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>sign demo</title>
</head>
<body>
  <button id="btn">发送请求</button>

  <script>
    function fakeMd5(str) {
      let hash = 0;
      for (let i = 0; i < str.length; i++) {
        hash = ((hash << 5) - hash) + str.charCodeAt(i);
        hash |= 0;
      }
      return "m" + Math.abs(hash).toString(16);
    }

    function makeSign(url, body, ts) {
      const secret = "demo_secret";
      const raw = url + "|" + body + "|" + ts + "|" + secret;
      return fakeMd5(raw);
    }

    async function requestApi() {
      const url = "/api/order/list";
      const bodyObj = { page: 1, size: 20 };
      const body = JSON.stringify(bodyObj);
      const ts = String(Date.now());
      const sign = makeSign(url, body, ts);

      console.log("最终签名:", sign);

      return fetch("https://httpbin.org/anything", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "x-ts": ts,
          "x-sign": sign
        },
        body
      });
    }

    document.getElementById("btn").addEventListener("click", requestApi);
  </script>
</body>
</html>

你可以先把上面的网络 Hook 注入这个页面,观察一遍请求发送过程,再去分析真实站点,思路会更顺。


逐步验证清单

很多人会在“好像看到了”这一步停住,但真正可复现的分析,最好一项项验证。

验证 1:签名字段是否稳定存在

重复触发 3~5 次请求,观察:

  • 参数名是否固定
  • 是否每次都带上
  • Header / Body 位置是否变化

验证 2:签名是否与时间戳绑定

手动比对:

  • 改变 timestamp
  • 业务参数不变
  • sign 是否变化

如果强绑定时间戳,说明签名输入里通常包含 ts

验证 3:签名是否与 Body 字段顺序有关

这一点非常常见。很多签名是对字符串做摘要,而不是对对象语义做摘要。

例如下面两段对象语义一样,但字符串不同:

JSON.stringify({a:1, b:2});
JSON.stringify({b:2, a:1});

如果结果字符串不同,签名就可能不同。

验证 4:签名逻辑是否有环境依赖

检查是否读取了:

  • navigator.userAgent
  • location.href
  • document.cookie
  • localStorage
  • sessionStorage

如果有,就意味着你在浏览器外复现时,要补环境。

可以用下面这张图理解验证顺序:

flowchart LR
    A[抓到最终请求] --> B[确认 sign 位置]
    B --> C[确认关联字段]
    C --> D[Hook 发送层]
    D --> E[Hook 加密原语]
    E --> F[定位签名函数]
    F --> G[验证输入输出]
    G --> H[判断是否可脱离浏览器复现]

常见坑与排查

这一节是我觉得最有价值的部分。因为真正花时间的,往往不是“不会 Hook”,而是 Hook 了但结论不可靠。

1. Hook 太晚,错过初始化阶段

有些站点在页面加载初期就完成:

  • token 初始化
  • wasm 加载
  • 加密函数缓存
  • 请求实例封装

你如果在操作按钮之后才注入 Hook,可能已经错过了关键逻辑。

排查办法

  • 尽量在页面刚加载时就注入
  • 用 Snippets + 手动刷新后立即执行
  • 必要时用 Tampermonkey 提前注入

2. 被框架封装,表面看不到真实请求参数

例如 Axios 会在请求拦截器里改 headers。你在业务代码里看到的对象,可能还不是最终结果。

排查办法

优先 Hook 更底层的位置:

  • fetch
  • XMLHttpRequest.send
  • Axios interceptors.request.use

如果页面暴露了 Axios 实例,也可以这样挂:

if (window.axios) {
  window.axios.interceptors.request.use(function(config) {
    console.group("[axios request]");
    console.log(config.url);
    console.log(config.headers);
    console.log(config.data);
    console.trace();
    console.groupEnd();
    return config;
  });
}

3. 代码被混淆,全局搜索不到关键字

压缩后可能出现:

a[b+c]=d(e,f,g)

这时候强行搜源码效率很低。

排查办法

  • 从 Hook 的调用栈跳回源码位置
  • 使用 Pretty Print 格式化
  • 在可疑函数入口打断点
  • 观察局部变量的值,而不是纠结变量名

4. 签名在 evalnew Function 中生成

有些站点为了增加分析成本,会动态执行代码。

排查办法

Hook 动态执行入口:

(() => {
  const rawEval = window.eval;
  window.eval = function(code) {
    console.group("=== eval hook ===");
    console.log(code);
    console.trace("eval stack");
    console.groupEnd();
    return rawEval.call(this, code);
  };

  const RawFunction = window.Function;
  window.Function = function(...args) {
    console.group("=== Function hook ===");
    console.log("args:", args);
    console.trace("Function stack");
    console.groupEnd();
    return RawFunction.apply(this, args);
  };
})();

这里要注意,Hook Function 有概率影响页面行为,建议短时使用。


5. 签名并不是单纯哈希,而是“排序 + 拼接 + 编码 + 摘要”

这是最常见的误判来源。你看到最终函数是 md5(...),不代表签名逻辑只有 MD5。

很多真实流程其实是:

  1. 参数排序
  2. 去空值
  3. 拼接固定分隔符
  4. 拼接 secret
  5. URL 编码或 Base64
  6. 再做 MD5/SHA

排查办法

不要只盯“最后一跳”,而要把进入摘要函数前的原始字符串抓出来。


6. 误把响应验签逻辑当成请求签名逻辑

页面中可能同时存在:

  • 请求签名
  • 响应解密
  • 响应验签
  • 上报埋点签名

排查办法

始终围绕一个具体接口跟踪,确认:

  • 请求发出前谁改了 Header/Body
  • 这个函数是否只在目标接口发送前触发

安全/性能最佳实践

这部分不是“站在道德高地说教”,而是帮你把调试过程做得更稳,不容易把页面搞崩。

1. Hook 要最小化、可撤销

不要一上来 Hook 十几个 API。建议:

  • 先 Hook fetch/XHR
  • 再按需 Hook JSON.stringifybtoadigest
  • 记录原始函数,必要时恢复

恢复示例:

const rawFetch = window.fetch;
// ...hook
window.fetch = rawFetch;

2. 避免在高频函数里打印超大对象

例如 JSON.stringifyArray.prototype.push 这类函数可能调用极多。
如果每次都 console.trace(),页面会明显卡顿。

建议:

  • 增加 URL 过滤
  • 增加次数限制
  • 只在命中关键词时 debugger

示例:

let hit = 0;
const maxHit = 5;
const raw = JSON.stringify;

JSON.stringify = function(...args) {
  const text = args[0] && typeof args[0] === "object" ? Object.keys(args[0]).join(",") : "";
  if (text.includes("sign") && hit < maxHit) {
    hit++;
    console.log("hit stringify:", args[0]);
    debugger;
  }
  return raw.apply(this, args);
};

3. 不要在未知站点随意执行来源不明的调试脚本

这是很实际的安全问题。你在控制台里执行的脚本,拥有和页面几乎同级的能力。
尤其不要直接运行不明来源的“万能 Hook 脚本”。

建议:

  • 自己理解每一行代码再执行
  • 在测试环境优先复现
  • 不在生产后台或敏感账号环境下随便注入

4. 明确边界:前端可见,不代表可任意复用

即使你已经定位到签名逻辑,也可能仍受限于:

  • 动态 token
  • 设备指纹
  • 服务端风控
  • 短时效密钥
  • 登录态绑定

也就是说,定位签名函数 ≠ 一定能在浏览器外完整复现
中级学习者最容易在这里产生错觉:算法明明看到了,为什么还不通?
通常不是算法错,而是上下文没补齐。


一个更贴近真实项目的定位策略

如果你面对的是 Webpack/Vite 打包后的大型项目,我建议按下面这个优先级执行:

策略 A:先抓“最终请求现场”

目标:

  • 找到 sign 在哪
  • 找到谁发的请求
  • 找到调用栈

工具:

  • Network
  • fetch/XHR Hook

策略 B:再抓“摘要前的明文”

目标:

  • 找到真正输入摘要函数的字符串
  • 确认排序、拼接、编码细节

工具:

  • Hook JSON.stringify
  • Hook btoa
  • Hook CryptoJS.MD5/SHA
  • Hook crypto.subtle.digest

策略 C:最后才读混淆源码

目标:

  • 补全上下文
  • 确认依赖项
  • 提炼可复现逻辑

工具:

  • Pretty Print
  • Scope 变量观察
  • Blackbox 无关脚本

很多时候,前两步做到位,源码其实只需要读 20% 左右。


总结

定位前端签名参数生成逻辑,最有效的方法不是“猜算法”,而是遵循这条路径:

  1. 先在 Network 确认签名出现的位置
  2. 从请求发送层 Hook,拿到最终参数和调用栈
  3. 再 Hook 加密原语,抓摘要前的输入
  4. 最后回到源码,补齐排序、拼接、编码和环境依赖

如果你只能记住一句话,我建议记这个:

不要从混淆代码里盲找 sign,要从请求发出前的那一刻反推。

这是我自己做 Web 逆向时最常用、也最省时间的一套办法。它的优点是:

  • 不依赖特定框架
  • 对混淆代码也有效
  • 能快速缩小排查范围

但也要明确边界:

  • 如果签名依赖浏览器环境、设备指纹、WASM、动态密钥,定位后仍需补环境
  • 如果 Hook 过多,会影响页面性能,甚至干扰原逻辑
  • 如果目标站点有安全防护,调试过程也要注意注入时机和副作用

最后给你一个可执行建议:
下次碰到签名接口时,不要急着搜 sign,先做一个最小版 fetch/XHR Hook
只要你能看到“请求发出去前最后一刻发生了什么”,后面的分析通常就顺了。


分享到:

上一篇
《自动化测试中的稳定性治理实战:从脆弱用例定位到持续集成回归提效》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-163》