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

《Web逆向实战:从浏览器抓包到还原加签逻辑的完整分析方法》

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

Web逆向实战:从浏览器抓包到还原加签逻辑的完整分析方法

做 Web 逆向时,很多人一上来就盯着混淆 JS 猛看,结果看了半天还是不知道签名到底怎么来的。
我自己早期也吃过这个亏:代码打开几千行,变量名全是 _0x12ab,看得头皮发麻,最后发现其实只要先把请求链路理清,定位到“谁在什么时候生成了什么”,难度会直接下降一个量级。

这篇文章我不打算只讲“怎么抄代码”,而是带你走一遍更稳定的分析路径:从浏览器抓包开始,逐步定位请求、还原参数、确认加签输入、复现签名输出,最后用脚本跑通。

说明:本文用于学习 Web 安全研究、协议分析和前端调试方法。请仅在合法授权范围内使用。


背景与问题

现在很多 Web 接口为了防止被随意调用,都会加上类似下面这些字段:

  • sign
  • token
  • timestamp
  • nonce
  • x-s
  • x-sign
  • authorization
  • 自定义请求头里的摘要值

从现象上看,你会遇到这些问题:

  1. 同样的接口,浏览器里能成功,请求工具里却返回“签名错误”
  2. 参数明明一样,但服务端还是提示“非法请求”
  3. 复制了请求头,过几秒就失效
  4. 看起来是明文参数,实际上关键值在运行时拼接
  5. 加签逻辑不在主业务代码里,而是在 webpack 模块、闭包、hook 包装函数甚至 WebAssembly 里

所以真正的核心问题不是“如何看懂混淆代码”,而是:

  • 这个签名是对哪些数据做的?
  • 签名生成发生在请求流程的哪个阶段?
  • 是同步生成还是异步依赖其他状态?
  • 签名依赖固定密钥、动态 token、Cookie 还是设备指纹?

前置知识

建议你至少熟悉这些内容:

  • 浏览器开发者工具(Network / Sources / Console)
  • 基本 HTTP 请求结构
  • JavaScript 基础语法
  • 常见摘要算法概念:MD5 / SHA1 / SHA256 / HMAC
  • Node.js 基本运行方式

如果你会一点这些工具会更顺手:

  • Chrome DevTools
  • Fiddler / Charles / mitmproxy
  • Node.js
  • Python requests
  • Pretty Print、全局搜索、XHR/fetch 断点

环境准备

本文演示尽量用通用方式,不依赖特定站点。

建议环境:

node -v
python --version
google-chrome --version

常用工具清单:

  • Chrome 浏览器
  • Node.js 16+
  • Python 3.9+
  • 一个抓包代理工具(可选)
  • 文本编辑器或 IDE

核心原理

从浏览器抓包到还原加签,本质上是在回答一条链路上的 4 个问题:

  1. 请求发给谁
  2. 请求带了什么
  3. 这些参数在前端哪里生成
  4. 生成规则能否脱离浏览器独立复现

一个最实用的分析框架

我通常按下面这个顺序做:

flowchart TD
    A[浏览器触发请求] --> B[Network定位目标接口]
    B --> C[比对请求参数/Header/Cookie]
    C --> D[识别动态字段 sign ts nonce token]
    D --> E[Sources全局搜索字段名]
    E --> F[定位加签函数]
    F --> G[分析输入 拼接顺序 编码方式]
    G --> H[脚本复现签名]
    H --> I[验证请求可独立重放]

这个流程的好处是:
先找证据,再找代码;先缩小范围,再啃逻辑。

常见加签模式

Web 场景里常见的签名,大致有这些套路:

  1. 简单拼接后摘要
    • md5(path + ts + secret)
  2. 参数排序后摘要
    • sha256(k1=v1&k2=v2&ts=xxx + secret)
  3. HMAC
    • hmac_sha256(payload, secret)
  4. 请求体摘要 + token 混合
    • sign = md5(bodyDigest + token + ts)
  5. 多段来源混合
    • URL 参数 + body + cookie + localStorage 值
  6. 前端环境参与
    • User-Agent、屏幕参数、Canvas 指纹、随机数

签名分析的三个关键点

1. 输入到底有哪些

这是最容易漏掉的地方。
很多人只看 body,结果签名实际上还依赖:

  • 路径:/api/v1/search
  • 查询串:page=1&q=test
  • 时间戳:ts
  • 随机串:nonce
  • Cookie 中的某个会话值
  • localStorage / sessionStorage 中的 token
  • 某个固定版本号

2. 参数顺序和编码方式

即使参数一样,顺序和编码不同,签名也完全不同。常见差异有:

  • 是否按 key 排序
  • 是否过滤空值
  • 是否 URL encode
  • 是否 JSON stringify
  • stringify 时 key 顺序是否固定
  • 拼接分隔符是 &,| 还是空字符串

3. 时效性与上下文

有些签名不是单独一个函数就够了,而是依赖上下文状态:

  • token 是否已初始化
  • 页面加载后是否下发 seed
  • sign 是否与当前 Cookie 绑定
  • 时间戳是否必须在 5 秒窗口内
  • 服务端是否校验 nonce 去重

抓包与定位:先缩小问题范围

第一步:在 Network 面板找目标请求

先打开浏览器开发者工具,切到 Network,然后操作页面触发接口。

优先观察这些项:

  • Request URL
  • Request Method
  • Query String Parameters
  • Form Data / Request Payload
  • Request Headers
  • Response

如果你已经怀疑有加签,重点盯:

  • URL 参数里是否有 signtsnonce
  • Header 里是否有 x-signauthorization
  • Cookie 是否参与身份绑定

第二步:多抓几次,做对比

抓一条请求往往不够,建议至少抓 3 次。
对比方法很简单:只改变一个变量,然后看哪些字段变了。

例如:

  • 同样参数,隔 2 秒再请求一次
  • 改一个查询参数
  • 切换页码
  • 登录前后各抓一次

这样你就能判断:

  • 哪些值是时间相关
  • 哪些值是参数相关
  • 哪些值是身份相关

第三步:构造“输入-输出”样本

举个例子,你可能得到下面这种观察结果:

次数qpagetssign
1phone11710000001a1b2c3…
2phone11710000003d4e5f6…
3case1171000000591aa2b…

这说明至少:

  • signts 有关
  • signq 也有关
  • 可能还和固定 secret 或 token 有关

如何在 JS 里定位加签逻辑

找到请求之后,下一步不是漫无目的搜代码,而是针对“动态字段”做反查。

方法一:全局搜索字段名

最先搜这些词:

  • sign
  • nonce
  • timestamp
  • x-sign
  • 请求路径关键字
  • 某个固定 header 名

如果字段名没有直接出现,可能是压缩后被改写了,那就继续用下面的方法。

方法二:给 XHR/fetch 打断点

很多站点现在用 fetch,有些还混着 XMLHttpRequest
你可以在 DevTools 的 Sources 面板中设置:

  • XHR/fetch Breakpoints
  • 对某个 URL 关键字打断点

一旦请求发出,执行会停在调用栈附近。
这个位置非常重要,因为你能看到:

  • 请求参数在发送前的最终值
  • 调用栈上层是谁组装了 sign
  • 某个 header 是在哪一层注入的

方法三:Hook 关键 API

如果代码过于混乱,我通常会直接在 Console 里 hook。

下面这个脚本可以监听 fetch

(function () {
  const rawFetch = window.fetch;
  window.fetch = async function (...args) {
    const [url, config] = args;
    console.log("fetch url:", url);
    console.log("fetch config:", config);
    debugger;
    return rawFetch.apply(this, args);
  };
})();

如果站点走 XHR,可以 hook opensend

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

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

  XMLHttpRequest.prototype.send = function (body) {
    console.log("xhr method:", this._method);
    console.log("xhr url:", this._url);
    console.log("xhr body:", body);
    debugger;
    return rawSend.call(this, body);
  };
})();

这类方法的优势是:
不需要先看懂所有源码,就能把请求发起前的真实数据抓出来。


实战案例:还原一个典型前端加签流程

下面我用一个“教学型示例”来演示完整思路。
假设页面会请求:

POST /api/search
Content-Type: application/json
X-Token: user_token_abc
X-Sign: 9f1d...

{"q":"phone","page":1,"ts":1710000001}

我们通过抓包和断点观察,发现签名逻辑大致是:

  1. 取请求体 JSON 字符串
  2. 取请求头里的 X-Token
  3. 取固定版本号 v1
  4. body + "|" + token + "|" + version 拼接
  5. 对拼接结果做 SHA256,输出十六进制小写

还原流程图

sequenceDiagram
    participant U as 用户操作
    participant B as 浏览器页面
    participant S as 加签函数
    participant A as 接口服务端

    U->>B: 点击搜索
    B->>B: 组装 payload(ts/q/page)
    B->>S: 传入 body + token + version
    S-->>B: 返回 X-Sign
    B->>A: POST /api/search + X-Sign
    A-->>B: 校验成功并返回结果

浏览器侧模拟代码

先写一个与页面逻辑一致的前端版本:

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

async function buildSign(body, token, version = "v1") {
  const raw = `${body}|${token}|${version}`;
  return await sha256Hex(raw);
}

async function demo() {
  const payload = {
    q: "phone",
    page: 1,
    ts: 1710000001
  };

  const body = JSON.stringify(payload);
  const token = "user_token_abc";
  const sign = await buildSign(body, token);

  console.log("body:", body);
  console.log("sign:", sign);
}

demo();

Node.js 可运行复现脚本

实际脱离浏览器跑任务时,我更常用 Node.js:

const crypto = require("crypto");

function buildSign(body, token, version = "v1") {
  const raw = `${body}|${token}|${version}`;
  return crypto.createHash("sha256").update(raw, "utf8").digest("hex");
}

function buildRequestData(q, page, token) {
  const payload = {
    q,
    page,
    ts: Math.floor(Date.now() / 1000)
  };

  const body = JSON.stringify(payload);
  const sign = buildSign(body, token);

  return {
    payload,
    headers: {
      "Content-Type": "application/json",
      "X-Token": token,
      "X-Sign": sign
    }
  };
}

const token = "user_token_abc";
const result = buildRequestData("phone", 1, token);

console.log(JSON.stringify(result, null, 2));

Python 请求复现

如果你的后续流程在 Python 中,可以这样写:

import time
import json
import hashlib
import requests

def build_sign(body: str, token: str, version: str = "v1") -> str:
    raw = f"{body}|{token}|{version}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()

def send_request():
    url = "https://example.com/api/search"
    token = "user_token_abc"

    payload = {
        "q": "phone",
        "page": 1,
        "ts": int(time.time())
    }

    body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
    sign = build_sign(body, token)

    headers = {
        "Content-Type": "application/json",
        "X-Token": token,
        "X-Sign": sign
    }

    resp = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=10)
    print(resp.status_code)
    print(resp.text)

if __name__ == "__main__":
    send_request()

这里我特意用了:

json.dumps(payload, separators=(",", ":"))

因为很多签名对 JSON 字符串的空格都敏感。
你在浏览器里看到的是对象,服务端校验的往往是序列化后的精确字符串。


逐步验证清单

做这类逆向时,不要一口气“猜完整逻辑”,而要逐步验证。

第 1 步:验证签名输入是否找全

你可以先固定其它值,只改一个字段:

  • 只改 q
  • 只改 page
  • 只改 ts
  • 只改 token

看签名是否变化,确认依赖关系。

第 2 步:验证拼接顺序

比如你怀疑是:

body|token|v1

也可以尝试:

token|body|v1
body|v1|token

如果站点逻辑比较简单,这一步很快就能撞出来。

第 3 步:验证编码一致性

重点检查:

  • UTF-8 还是其他编码
  • 十六进制大小写
  • Base64 是否去掉 =
  • JSON 是否有空格
  • 参数是否 URL encode

第 4 步:验证是否还有隐含上下文

如果单独脚本算出来的 sign 与浏览器一致,但请求还是失败,问题大概率在:

  • Cookie 没带全
  • Referer / Origin 被校验
  • 服务端还校验 token 与 sign 的绑定关系
  • 时间戳窗口过期
  • 某个 nonce 已被服务端消费

复杂场景下的定位策略

有些站点不会把签名函数直接摆在你面前,而是做了很多包装。

场景一:webpack 打包 + 模块化很深

特征:

  • 代码都在匿名函数和模块编号里
  • 一个请求要跨好几个模块调用

建议做法:

  1. 对请求 URL 打 XHR/fetch 断点
  2. 看调用栈
  3. 从最接近请求发送的位置往上追
  4. 找出“最后一次赋值 sign”的地方

场景二:代码混淆严重

特征:

  • 变量名不可读
  • 大量数组映射
  • 控制流平坦化

建议做法:

  • 不先做全文阅读
  • 先 hook fetch / xhr
  • 再 hook crypto 相关函数
  • 记录调用入参和返回值

比如 hook JSON.stringify 或摘要函数附近逻辑:

(function () {
  const rawStringify = JSON.stringify;
  JSON.stringify = function (...args) {
    const result = rawStringify.apply(this, args);
    console.log("JSON.stringify input:", args[0]);
    console.log("JSON.stringify output:", result);
    return result;
  };
})();

场景三:签名依赖异步初始化

特征:

  • 页面刚打开时请求失败,过一会儿成功
  • 某个 token 是接口先下发的
  • sign 依赖动态 seed

这种情况要先梳理状态机:

stateDiagram-v2
    [*] --> Init
    Init --> GetSeed: 页面加载
    GetSeed --> SeedReady: seed/token下发成功
    SeedReady --> BuildSign: 用户触发请求
    BuildSign --> SendRequest
    SendRequest --> [*]

如果你跳过 GetSeed 直接模拟最终接口,往往一定失败。


常见坑与排查

这一部分很重要,我尽量写得“接地气”一点。

1. 只看请求参数,不看 Header

很多签名其实根本不在 body,而是在 Header。
尤其是:

  • authorization
  • x-sign
  • x-t
  • x-s
  • x-token

排查建议:
抓包时把 Request Headers 展开,不要只盯 Payload。


2. 误把“显示值”当“参与签名的值”

有些参数在页面对象里长这样:

{
  q: "phone",
  page: 1
}

但实际参与签名的是:

'{"page":1,"q":"phone"}'

也可能是排序后的 querystring:

page=1&q=phone

排查建议:

  • 看签名前最后一次字符串化结果
  • hook JSON.stringify
  • hook URLSearchParams 的生成逻辑

3. 时间戳精度搞错

常见差异:

  • 秒级:1710000001
  • 毫秒级:1710000001000

有些接口还会把时间戳转成字符串再参与拼接。

排查建议:

  • 抓两次请求,算一下差值
  • 看数值长度是 10 位还是 13 位

4. JSON 序列化不一致

这是我自己踩过很多次的坑。
浏览器里可能是:

JSON.stringify(payload)

而你 Python 默认 json.dumps 会带空格,结果签名永远不对。

排查建议:

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

并确认 key 顺序是否一致。


有些 sign 本身没问题,但服务端还会做“上下文绑定”:

  • sign 对应的 token 来自 localStorage
  • token 对应的会话来自 Cookie
  • Cookie 不匹配就报签名错误

排查建议:

在浏览器 Console 查看:

localStorage
sessionStorage
document.cookie

并对照请求头检查是否全部带上。


6. 复制代码后在 Node 中直接运行失败

原因往往是浏览器环境对象缺失:

  • window
  • document
  • navigator
  • crypto.subtle
  • atob / btoa

排查建议:

  • 先最小化抽离:只提取真正的签名函数
  • 必要时做环境补丁
  • 不要一开始就搬整个混淆文件进 Node

7. 抓到的是“中间值”,不是最终值

例如某个函数先算出一个摘要 A,后面又做了一层编码或拼接,最终发出去的是 B。
只盯住前半段,很容易误判“算法已经还原”。

排查建议:

最终以 Network 里真正发出的值 为准,逐层对照。


安全/性能最佳实践

这部分不只是“怎么逆”,也包括怎么更稳地做分析和复现。

1. 先证据链,后代码阅读

不要一上来就打开大文件硬啃。
正确顺序应该是:

  1. 抓包
  2. 对比样本
  3. 定位动态字段
  4. 断点/Hook
  5. 抽离函数
  6. 脚本复现

这样效率高很多。

2. 保留原始样本

建议你保存:

  • 原始请求 URL
  • Header
  • Cookie
  • Payload
  • 返回结果
  • 时间点

最好整理成一个小目录。
后面复现失败时,你才能快速比对“是算法错了,还是上下文缺了”。

3. 抽离最小可用签名函数

做脚本化复现时,目标不是把整站前端复制下来,而是提取最小依赖集。

理想状态下,你最终只保留:

  • 一个 buildSign
  • 一个 buildPayload
  • 一个 sendRequest

这样可维护性最好。

4. 控制请求频率

即使你已经还原了签名,也不要高频压接口。
很多站点有:

  • IP 限流
  • 用户行为风控
  • nonce 重放检测
  • 时间窗口校验

频率过高会触发风控,让你误以为“签名算法错了”。

5. 记录每一步验证结果

我个人很推荐你用表格或日志记:

  • 输入参数
  • token
  • ts
  • 原始拼接串
  • 中间摘要
  • 最终 sign
  • 服务端返回

一旦失败,能迅速回滚到上一层排查。


一套可复用的分析模板

如果你想把这套方法固化成习惯,可以按这个模板执行:

flowchart LR
    A[抓3次包] --> B[找动态字段]
    B --> C[判断字段依赖关系]
    C --> D[URL关键字断点]
    D --> E[定位签名生成函数]
    E --> F[确认输入与顺序]
    F --> G[确认编码与摘要算法]
    G --> H[Node/Python复现]
    H --> I[带Cookie/Token完整验证]

对应的落地动作是:

  1. 抓三次包,做对比
  2. 锁定动态字段
  3. 给请求断点
  4. 看调用栈
  5. 抽出签名函数
  6. 用固定样本验证
  7. 再接入动态时间戳与 token
  8. 最后发真实请求验证

总结

Web 逆向里最怕的不是混淆,而是没有方法
只要你按“抓包定位 → 识别动态字段 → 断点追调用栈 → 确认输入与编码 → 脚本复现”的路径走,大多数前端加签都能拆开。

你可以记住这几个关键结论:

  • 先抓包,再看代码
  • 先确认签名输入,再猜算法
  • 最终以网络面板里真正发出的值为准
  • JSON、排序、编码、时间戳精度,是最常见的坑
  • 能抽最小函数就不要整包迁移

如果你现在就要动手,我建议从下面这个最小行动清单开始:

  1. 抓同一接口 3 次包
  2. 标出每次变化的字段
  3. 对目标 URL 打 XHR/fetch 断点
  4. 找到 sign 最终赋值点
  5. 打印签名前的原始拼接字符串
  6. 用 Node.js 独立复现并对比浏览器结果

当你能稳定做到这 6 步时,很多看起来“很玄学”的加签逻辑,其实都会变得非常具体。
这也是 Web 逆向真正的门槛:不是某个神秘算法,而是你能不能把过程拆清楚、验证清楚。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性治理》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建优化到生产环境安全交付》