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

《从抓包到补环境:中级开发者实战 Web 逆向中的前端加密参数还原》

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

从抓包到补环境:中级开发者实战 Web 逆向中的前端加密参数还原

做 Web 逆向时,很多人卡的不是“包抓不到”,而是“包抓到了也发不出去”。接口参数看起来齐全,Cookie 也带了,请求头也像了,但服务端就是回你一句:sign invalidillegal request 或者干脆空数据。

这篇文章我想从一个中级开发者真正会遇到的场景出发,带你走一遍完整流程:抓包定位 -> 找加密逻辑 -> 分析依赖 -> 补环境执行 -> 还原请求参数。重点不是某个站点的细节,而是这类问题背后的通用方法。

说明:本文内容用于安全研究、接口联调、自有系统测试与前端逻辑分析,请勿用于未授权的数据抓取或绕过访问控制。


背景与问题

典型场景是这样的:

  • 页面里通过 XHR / Fetch 请求数据
  • 参数里有 signtokentnonceencryptData 等字段
  • 这些字段不是固定值,而是每次请求动态生成
  • 单纯复制浏览器中的请求参数,只能复现一次,甚至一次都不行
  • 直接用 Python/Node 重放请求,服务端校验失败

这背后通常有三类前端保护逻辑:

  1. 纯拼接签名:例如 md5(path + data + timestamp + secret)
  2. 轻度加密:AES/RSA/SM4 等对请求体或关键字段加密
  3. 环境绑定:签名逻辑依赖 windowdocumentnavigatorlocation、Canvas、WebGL、LocalStorage 等浏览器对象

真正难的往往不是算法本身,而是第三类:代码能找到,但在 Node.js 里跑不起来。这时就进入“补环境”阶段了。


前置知识

如果你已经熟悉以下内容,阅读会顺很多:

  • Chrome DevTools 的 Network、Sources、Debugger
  • 基本 JavaScript 语法与闭包、原型链
  • Node.js 运行方式
  • 常见摘要/加密算法:MD5、SHA1、SHA256、AES、RSA
  • 抓包工具基础:浏览器开发者工具、Charles、Fiddler、mitmproxy

环境准备

本文示例使用:

  • Chrome
  • Node.js 18+
  • 一个文本编辑器或 VS Code

建议准备以下辅助能力:

  • 浏览器格式化混淆代码的习惯
  • 会打 XHR/fetch 断点
  • 会用 console.log 临时插桩
  • 知道如何将浏览器里的 JS 片段抽离到 Node 运行

整体流程先看一眼

先别急着抠代码,我建议先建立一个整体框架。很多时候逆向失败,不是技术不会,而是顺序乱了。

flowchart TD
    A[抓包定位目标接口] --> B[分析请求参数结构]
    B --> C[搜索 sign/token 生成点]
    C --> D[断点跟栈追踪]
    D --> E[抽离核心函数]
    E --> F{是否依赖浏览器环境}
    F -- 否 --> G[Node 直接运行验证]
    F -- 是 --> H[补 window/document/navigator 等]
    H --> I[还原参数生成]
    G --> J[重放请求验证]
    I --> J
    J --> K[封装脚本与排查异常]

核心原理

前端加密参数还原,本质上是在回答三个问题:

1. 参数是怎么生成的?

比如一个请求:

{
  "page": 1,
  "size": 20,
  "t": 1710000000000,
  "nonce": "a8sd9f",
  "sign": "4c7d..."
}

你需要确认:

  • sign 用了哪些字段参与计算
  • 字段顺序是否固定
  • 是否做了 JSON 序列化
  • 是否进行了 URL 编码
  • 是否拼接了固定盐值
  • 时间戳单位是秒还是毫秒

很多人只看到 md5 就开始模仿,但真正校验失败的原因,常常是序列化细节不一致


2. 参数生成依赖哪些运行环境?

比如函数内部可能用了:

  • navigator.userAgent
  • window.location.href
  • document.cookie
  • localStorage.getItem("token")
  • Date.now()
  • Math.random()

如果你把浏览器代码直接复制到 Node 中,常见报错就是:

  • window is not defined
  • document is not defined
  • navigator is not defined
  • Cannot read properties of undefined

这说明不是算法难,而是运行时上下文缺了


3. 服务端校验关注的是“值”还是“过程”?

有的接口只认结果,只要你算出的 sign 对就行;
有的接口还会校验:

  • 时间窗口
  • 请求顺序
  • Cookie/Session 绑定
  • Token 是否从上一步接口获得
  • 设备指纹与签名是否一致

也就是说,签名还原成功不代表请求一定成功。这点在排查时非常关键。


一个典型请求的逆向思路

我们以一个中等复杂度的场景举例:

  • 请求方法:POST
  • 请求体:JSON
  • 参数中有 timestampnoncesign
  • sign 由请求体、时间戳、UA 和本地 token 共同计算
  • 代码混淆后运行在浏览器里

它的实际调用关系,通常像这样:

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名模块
    participant B as 浏览器环境
    participant A as 接口服务端

    U->>P: 点击查询
    P->>B: 读取 cookie/localStorage/UA/时间
    P->>S: 传入 body + env
    S-->>P: 返回 sign/timestamp/nonce
    P->>A: 携带加密参数发请求
    A-->>P: 校验成功返回数据

背景与问题:从抓包开始看

假设我们在浏览器开发者工具里看到一个请求:

POST /api/data/list HTTP/1.1
Content-Type: application/json
X-Token: 9f3a...

请求体:

{
  "page": 1,
  "size": 20,
  "keyword": "phone",
  "timestamp": 1710000000123,
  "nonce": "m8K2pQ",
  "sign": "9f6e3c0d..."
}

而你把这个 body 原样复制到 Python 或 Postman 里重新发,请求失败。说明这里至少有一个事实成立:

  • sign 与当前请求上下文强绑定;
  • 或者 timestamp 已过期;
  • 或者 nonce 只能用一次;
  • 或者 sign 依赖别的头信息、Cookie、token。

这时第一步不是写代码,而是重新在浏览器里做“变量追踪”


第一步:抓包分析与定位入口

1. 先看 Network,不急着看 Sources

重点观察:

  • 请求 URL
  • 请求方法
  • Query 参数
  • Body 类型
  • Headers 中自定义字段
  • Cookie/Authorization 是否参与
  • 请求发起堆栈(Initiator)

如果浏览器支持,直接点请求的 Initiator,通常能跳到发请求的代码位置。这一步可以帮你快速定位调用链。

2. 搜索关键字段名

优先搜索:

  • sign
  • nonce
  • timestamp
  • 接口路径片段,如 /api/data/list
  • 自定义头名,如 X-Token

如果代码没被严重混淆,一般能找到签名入口函数。

3. 打断点看“调用前一刻”

我自己常用两种方式:

  • fetch / XMLHttpRequest.send 处打断点
  • 在构造请求参数的位置打断点

你要看的不是一坨混淆函数,而是:

  • 最终送给接口的参数长什么样
  • 在进入发送前,这些参数刚刚被谁赋值
  • 是哪一个函数返回了 sign

第二步:识别签名是“纯算法”还是“带环境”

判断方式很简单。

如果某段代码长这样:

function makeSign(data, ts, nonce) {
  const raw = JSON.stringify(data) + "|" + ts + "|" + nonce + "|SECRET";
  return md5(raw);
}

那大概率是纯算法,直接搬到 Node 就行。

但如果像这样:

function makeSign(data) {
  const token = localStorage.getItem("token") || "";
  const ua = navigator.userAgent;
  const href = location.href;
  const ts = Date.now();
  const nonce = randomString(6);
  const raw = JSON.stringify(data) + token + ua + href + ts + nonce;
  return {
    ts,
    nonce,
    sign: sha256(raw)
  };
}

这就是典型的带环境依赖,需要补环境。


第三步:抽离最小可运行代码

很多人一上来就把整份混淆 JS 扔进 Node,这样通常会被无关逻辑拖死。更稳的做法是:

  1. 找到签名入口函数
  2. 沿着调用链只提取必要函数
  3. 补最少的全局对象
  4. 先让它跑起来,再逐步补全

这个思路很像“做最小复现”。


实战代码(可运行)

下面我用一个可运行的模拟案例演示完整过程。它不是某个真实站点代码,但足够贴近实战。

浏览器侧原始逻辑(目标逻辑)

假设页面里真正执行的是下面这段代码:

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

function stableStringify(obj) {
  const keys = Object.keys(obj).sort();
  const ret = {};
  for (const k of keys) {
    ret[k] = obj[k];
  }
  return JSON.stringify(ret);
}

async function sha256(text) {
  const data = new TextEncoder().encode(text);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

async function buildParams(payload) {
  const ts = Date.now();
  const nonce = randomString(6);
  const token = localStorage.getItem("token") || "";
  const ua = navigator.userAgent;
  const href = location.href;
  const body = stableStringify(payload);
  const raw = [body, ts, nonce, token, ua, href].join("|");
  const sign = await sha256(raw);

  return {
    ...payload,
    timestamp: ts,
    nonce,
    sign
  };
}

这段逻辑依赖:

  • localStorage
  • navigator
  • location
  • crypto.subtle
  • TextEncoder

在 Node 中补环境并运行

下面给出 Node 18+ 可运行版本:

const crypto = require("crypto");

global.navigator = {
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0.0.0 Safari/537.36"
};

global.location = {
  href: "https://example.com/search"
};

global.localStorage = {
  _data: {
    token: "demo_token_123456"
  },
  getItem(key) {
    return this._data[key] || null;
  },
  setItem(key, value) {
    this._data[key] = String(value);
  }
};

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

function stableStringify(obj) {
  const keys = Object.keys(obj).sort();
  const ret = {};
  for (const k of keys) {
    ret[k] = obj[k];
  }
  return JSON.stringify(ret);
}

async function sha256(text) {
  return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}

async function buildParams(payload) {
  const ts = Date.now();
  const nonce = randomString(6);
  const token = localStorage.getItem("token") || "";
  const ua = navigator.userAgent;
  const href = location.href;
  const body = stableStringify(payload);
  const raw = [body, ts, nonce, token, ua, href].join("|");
  const sign = await sha256(raw);

  return {
    ...payload,
    timestamp: ts,
    nonce,
    sign
  };
}

(async () => {
  const payload = {
    page: 1,
    size: 20,
    keyword: "phone"
  };

  const params = await buildParams(payload);
  console.log(params);
})();

运行:

node demo.js

输出示例:

{
  page: 1,
  size: 20,
  keyword: 'phone',
  timestamp: 1710000000123,
  nonce: 'aZ8kP1',
  sign: 'a4b7c1d8...'
}

第四步:带请求重放的完整示例

只算出参数还不够,最好立刻验证“能不能发成功”。

下面给一个可运行的请求重放模板:

const crypto = require("crypto");

global.navigator = {
  userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/123.0.0.0 Safari/537.36"
};

global.location = {
  href: "https://example.com/search"
};

global.localStorage = {
  _data: {
    token: "demo_token_123456"
  },
  getItem(key) {
    return this._data[key] || null;
  }
};

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

function stableStringify(obj) {
  const keys = Object.keys(obj).sort();
  const ret = {};
  for (const k of keys) {
    ret[k] = obj[k];
  }
  return JSON.stringify(ret);
}

function sha256(text) {
  return crypto.createHash("sha256").update(text, "utf8").digest("hex");
}

function buildParams(payload) {
  const ts = Date.now();
  const nonce = randomString(6);
  const token = localStorage.getItem("token") || "";
  const ua = navigator.userAgent;
  const href = location.href;
  const body = stableStringify(payload);
  const raw = [body, ts, nonce, token, ua, href].join("|");
  const sign = sha256(raw);

  return {
    ...payload,
    timestamp: ts,
    nonce,
    sign
  };
}

async function main() {
  const payload = {
    page: 1,
    size: 20,
    keyword: "phone"
  };

  const body = buildParams(payload);

  const res = await fetch("https://httpbin.org/post", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Token": localStorage.getItem("token"),
      "User-Agent": navigator.userAgent
    },
    body: JSON.stringify(body)
  });

  const json = await res.json();
  console.log(JSON.stringify(json, null, 2));
}

main().catch(console.error);

这个示例主要用于验证两件事:

  1. 你的参数生成逻辑是否能稳定执行
  2. 你的请求重放流程是否完整

如果你在真实目标里替换 URL 和头部,这就成了最基础的自动化脚本骨架。


第五步:逐步验证清单

实战里我很少一步到位,而是按这个顺序验证:

验证 1:参数结构是否一致

核对:

  • 字段名完全一致
  • 大小写一致
  • 是否缺字段
  • 是否多字段
  • 空字符串与 null 是否被区别处理

验证 2:时间戳是否一致

核对:

  • 秒级还是毫秒级
  • 是否取整
  • 是否服务端要求固定时间窗口
  • 是否与请求头中的时间字段联动

验证 3:序列化结果是否一致

这是重灾区。

比如下面这几种看似差不多,实际哈希完全不同:

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

如果原站点做了 key 排序、过滤空值、数字转字符串,你都必须跟上。

验证 4:环境值是否一致

核对:

  • navigator.userAgent
  • location.href
  • document.referrer
  • localStorage 中 token
  • Cookie
  • 屏幕尺寸、时区、语言

验证 5:算法结果是否逐步对齐

如果浏览器里可断点,我建议直接在浏览器控制台打印中间值:

  • 原始拼接串 raw
  • 时间戳 ts
  • 随机串 nonce
  • token
  • 最终 sign

然后与你在 Node 中的每一步输出做 diff。
不要只对比最终 sign,那样排错成本太高。


补环境到底补什么?

补环境不是无脑造一个 window = {} 就完了。要根据报错和调用链来补。

下面是一个常见依赖图:

classDiagram
    class window {
      navigator
      location
      document
      localStorage
      sessionStorage
    }

    class navigator {
      userAgent
      language
      platform
    }

    class location {
      href
      host
      pathname
    }

    class document {
      cookie
      referrer
    }

    class localStorage {
      getItem()
      setItem()
    }

    window --> navigator
    window --> location
    window --> document
    window --> localStorage

最小补环境示例

global.window = global;

global.navigator = {
  userAgent: "Mozilla/5.0",
  language: "zh-CN",
  platform: "Win32"
};

global.location = {
  href: "https://example.com/path?a=1",
  host: "example.com",
  pathname: "/path"
};

global.document = {
  cookie: "sid=abc123; token=xyz789",
  referrer: "https://example.com/home"
};

global.localStorage = {
  _data: {},
  getItem(k) {
    return this._data[k] || null;
  },
  setItem(k, v) {
    this._data[k] = String(v);
  }
};

global.sessionStorage = {
  _data: {},
  getItem(k) {
    return this._data[k] || null;
  },
  setItem(k, v) {
    this._data[k] = String(v);
  }
};

注意一点:
补环境的目标不是“像浏览器一样完整”,而是“满足目标函数执行所需”。够用就行。


常见坑与排查

这一段我尽量写得接地气一点,因为这些坑我自己都踩过。

坑 1:只复刻了算法,没复刻输入

最常见。你看见 md5sha256 就觉得结束了,但服务端验的是:

  • 排序后的 JSON
  • URL 编码后的字符串
  • 带 token 的拼接串
  • 带固定前缀/后缀的内容

排查方式:打印浏览器端参与签名的原文字符串,与 Node 输出逐字符对比。


坑 2:对象顺序不一致

JavaScript 对象遍历顺序、构造方式、序列化前处理方式,都可能影响最终签名。

例如目标代码里用了:

Object.keys(data).sort()

而你直接:

JSON.stringify(data)

结果就不一样。

排查方式:自己实现 stableStringify,或者在浏览器里把签名前的字符串打印出来。


坑 3:随机数与时间戳没对齐

有些站点要求:

  • nonce 长度固定
  • 只能字母数字
  • 时间戳必须在服务端允许的偏差范围内
  • noncetimestamp 共同参与验签

排查方式

  • 检查 Date.now() 是否应转秒
  • 检查随机串字符集
  • 尝试在签名后立即发请求,避免过期

坑 4:Node 和浏览器的 API 不一致

比如浏览器里是:

crypto.subtle.digest(...)

Node 里你却直接照搬,结果报错。

排查方式

  • 浏览器 Web Crypto 在 Node 中不一定兼容调用方式
  • 简单哈希类操作优先替换为 require("crypto")
  • 若目标站点强依赖 window.crypto,再考虑更完整的 polyfill

坑 5:补环境补少了,或者补“假了”

有的代码会连环读取:

window.navigator.userAgent
window.location.href
document.cookie

你只补了 navigator,没补 window.navigator,仍然会炸。

排查方式

  • 报错看栈,不要猜
  • 从缺哪个属性补哪个属性
  • 适当在 getter 上打日志,观察访问路径

例如:

global.navigator = new Proxy({
  userAgent: "Mozilla/5.0"
}, {
  get(target, prop) {
    console.log("navigator get:", prop);
    return target[prop];
  }
});

这招在定位环境依赖时很实用。


坑 6:代码有反调试/自校验

一些前端代码会检测:

  • Function.prototype.toString
  • debugger
  • DevTools 开启状态
  • 代码是否被篡改
  • 是否在浏览器环境中

这类情况不是简单补环境就能过。

排查方式

  • 先找到真正业务签名入口
  • 避开外围反调试壳
  • 优先抽离已执行后的核心函数,而不是硬啃整包

说白了,别一上来就跟整套混淆对抗,先找“有效载荷”。


安全/性能最佳实践

虽然我们讨论的是逆向与参数还原,但落到工程实现,也要注意边界。

1. 不要把补环境脚本写成“全局污染怪兽”

建议把环境封装成工厂函数,避免不同接口逻辑互相影响。

function createEnv() {
  return {
    navigator: {
      userAgent: "Mozilla/5.0"
    },
    location: {
      href: "https://example.com/"
    },
    localStorage: {
      _data: { token: "demo" },
      getItem(k) {
        return this._data[k] || null;
      }
    }
  };
}

然后在签名函数里显式传入依赖,比全局乱挂更稳。


2. 优先做“最小可运行抽离”

整包执行有几个问题:

  • 依赖多
  • 容易被反调试干扰
  • 不利于维护

最好做法是:

  • 抽出核心签名函数
  • 只保留必要辅助函数
  • 补最少环境
  • 建立回归样例

3. 给签名逻辑做样例固化

一旦某个站点的签名跑通,建议立刻保存一组固定输入与输出。

例如:

const sampleInput = {
  page: 1,
  size: 20,
  keyword: "phone"
};

const fixedEnv = {
  token: "demo_token_123456",
  ua: "Mozilla/5.0",
  href: "https://example.com/search",
  ts: 1710000000123,
  nonce: "abc123"
};

这样以后站点更新了,你能快速判断:

  • 算法变了
  • 序列化变了
  • 还是环境变了

4. 控制请求频率,尊重授权边界

这不是套话,是真的重要。即使你能还原参数,也不代表你可以无约束地调用接口。

建议:

  • 仅在授权范围内分析
  • 控制请求频率
  • 不绕过鉴权
  • 不抓取敏感数据
  • 对自己的脚本设置速率限制与日志

5. 性能上优先缓存稳定环境值

像这些值通常没必要每次重新计算:

  • userAgent
  • location
  • 固定 token(若短时间不变)
  • 解析后的常量盐值

而这些值应每次实时生成:

  • timestamp
  • nonce
  • 部分一次性 token

这样脚本会更稳,调试也更容易。


一个实战中的推荐排查路径

如果你现在手头就有一个“请求发不出去”的目标,我建议按这个顺序做:

stateDiagram-v2
    [*] --> 抓包确认接口
    抓包确认接口 --> 找请求构造代码
    找请求构造代码 --> 找sign生成入口
    找sign生成入口 --> 打印中间变量
    打印中间变量 --> 抽离最小函数
    抽离最小函数 --> 补最小环境
    补最小环境 --> 本地生成sign
    本地生成sign --> 重放请求
    重放请求 --> 成功
    重放请求 --> 失败
    失败 --> 对比浏览器与本地输入
    对比浏览器与本地输入 --> 补充环境或修正序列化
    补充环境或修正序列化 --> 本地生成sign
    成功 --> [*]

这个路径的关键点是:
每一步都要有可验证产物
比如“中间原文字符串一致”“时间戳一致”“nonce 长度一致”“sign 一致”。

不要模糊地觉得“应该差不多了”。Web 逆向里,差一个字符都不行。


总结

从抓包到补环境,还原前端加密参数,核心不是“会不会某种算法”,而是建立一套稳定的方法论:

  1. 先抓包,确认请求结构
  2. 定位参数生成入口,而不是盲猜
  3. 判断是纯算法还是环境依赖
  4. 抽离最小可运行代码
  5. 按需补环境,不求完整浏览器
  6. 逐步对齐中间值,而不是只盯最终 sign
  7. 重放验证,闭环确认

如果你是中级开发者,我特别建议你把注意力从“搜现成脚本”转到“构建自己的排查框架”上。因为站点会变、混淆会变、参数名会变,但这套思路基本不变。

最后给几个很实用的可执行建议:

  • 第一时间打印“签名前原文”
  • 遇到报错,按调用栈补环境,不要盲补
  • 优先抽离核心函数,不要直接硬跑整包
  • 为每个已跑通的站点保留固定样例做回归
  • 如果接口校验失败,不只看 sign,还要看 Cookie、token、时效与上下文绑定

如果你把这套流程练熟,很多“看起来很玄学”的前端加密参数,其实都会变成一个个可拆解、可验证的工程问题。


分享到:

上一篇
《自动化测试中的测试数据管理实战:从数据构造、隔离到稳定回放的工程化方案》
下一篇
《前端性能实战:基于 Core Web Vitals 的页面加载优化与排查方案》