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

《从浏览器抓包到参数还原:中级开发者实战 Web 逆向中的接口签名分析与复现》

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

从浏览器抓包到参数还原:中级开发者实战 Web 逆向中的接口签名分析与复现

很多中级开发者第一次接触 Web 逆向时,最大的困惑不是“怎么抓包”,而是:

  • 包抓到了,但参数看不懂
  • 看到了 signtokennonce,却不知道谁参与了计算
  • 抄请求头发过去,结果还是 401403验签失败
  • 浏览器里能请求成功,脚本里却复现不出来

我自己刚开始做这类分析时,也踩过很典型的坑:以为复制一份请求就够了,后来才发现,真正难的是还原参数生成链路,而不是“看到参数值”。

这篇文章我会按一个比较贴近实战的顺序,带你从浏览器抓包开始,一步步走到:

  1. 定位签名参数位置
  2. 分析签名参与字段
  3. 还原前端生成逻辑
  4. 用脚本完成接口复现

文章会以一个教学化的模拟案例来演示,代码可直接运行。重点不是某个网站的细节,而是你可以迁移到大多数 Web 接口签名分析中的方法。


背景与问题

现代 Web 应用为了防止接口被随意调用,常见会增加一层或多层校验:

  • 时间戳 ts
  • 随机串 nonce
  • 摘要签名 sign
  • 登录态相关 token
  • 设备指纹、环境指纹
  • 请求体加密、字段混淆、排序拼接

对于中级开发者来说,真正的挑战通常不是 HTTP 本身,而是这几个问题:

1. 参数到底是谁生成的?

有些参数来自:

  • URL 查询串
  • Cookie
  • LocalStorage / SessionStorage
  • JS 运行时动态计算
  • Webpack 打包后的混淋函数
  • 请求拦截器统一注入

2. 为什么“复制请求”不等于“复现请求”?

因为接口签名往往和这些因素绑定:

  • 当前时间
  • 登录态
  • 请求路径
  • 请求体内容
  • Header 中的某些字段
  • 参数顺序

只复制某一次请求,不理解计算逻辑,下一次就失效。

3. 为什么浏览器能成功,脚本失败?

典型原因有:

  • 少了 Cookie 或 Authorization
  • 请求体 JSON 序列化方式不一致
  • Header 大小写、值格式不一致
  • 参与签名的字符串排序错了
  • Python 和 JS 对对象转字符串的行为不同

所以本文的核心不是“抓包工具怎么打开”,而是:如何把“抓到的结果”还原成“可重复生成的过程”


前置知识与环境准备

如果你已经熟悉这些,可以直接跳到实战部分。

建议具备的基础

  • 会使用浏览器开发者工具 Network 面板
  • 了解 HTTP 请求结构
  • 会一点 JavaScript 或 Python
  • 知道哈希函数如 MD5 / SHA256 的基本用途
  • 能接受“先猜测,再验证”的分析节奏

环境准备

本文示例使用:

  • Chrome 浏览器
  • Node.js 16+
  • Python 3.9+
  • 一个可运行的本地模拟服务

我们会构造一个“前端签名 + 后端验签”的最小案例,方便你理解真实站点里会发生什么。


分析全流程总览

先看全景,再进入细节。

flowchart TD
    A[浏览器抓包] --> B[定位目标接口]
    B --> C[识别关键参数 sign ts nonce]
    C --> D[全局搜索参数名]
    D --> E[定位发起请求代码]
    E --> F[还原签名拼接规则]
    F --> G[本地脚本复现]
    G --> H[逐项校验请求差异]
    H --> I[稳定调用接口]

这个流程里最容易卡住的是两段:

  • 从参数到代码
  • 从代码到可运行复现

后面我会重点讲这两步。


核心原理

Web 接口签名本质上是在做一件事:

把请求中的部分信息,按约定规则拼成一个字符串,再通过某种算法计算出摘要值。

后端收到请求后,使用同样规则重新计算,如果一致,就认为请求可信。


常见签名组成方式

一个常见的签名输入可能长这样:

method=POST&path=/api/search&ts=1690000000&nonce=abc123&body={"kw":"phone","page":1}&secret=xxxx

再经过:

  • MD5
  • SHA1
  • SHA256
  • HMAC-SHA256
  • AES 后再摘要
  • 多轮编码(URL encode / Base64 / Hex)

最终得到 sign

常见参与字段

字段作用
ts防重放,限制时间窗口
nonce防止相同请求重复使用
token绑定用户身份
path防止签名被用于其他接口
body绑定具体请求内容
secret前后端共享的隐藏常量或变种密钥

签名分析的思维模型

我建议把逆向签名分析拆成三层:

  1. 表象层:看到请求里有哪些字段
  2. 链路层:这些字段在哪段代码中被生成和注入
  3. 规则层:签名算法、排序规则、编码规则是什么

很多人卡在表象层:只盯着 Network 面板里的某个 sign 值,却不追到代码里去。

下面这个时序图可以帮助你建立“参数生成链”的感觉。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant S as 签名函数
    participant R as 请求拦截器
    participant A as API服务端

    U->>P: 点击搜索
    P->>S: 传入 body/path/token
    S->>S: 生成 ts/nonce/sign
    S-->>P: 返回签名参数
    P->>R: 发起请求
    R->>R: 注入 Header/Cookie
    R->>A: 发送完整请求
    A->>A: 按同规则验签
    A-->>R: 响应结果
    R-->>P: 返回数据

实战案例:从抓包到参数还原

下面我们做一个完整的教学案例。

目标接口定义

假设前端会调用:

POST /api/search
Content-Type: application/json

{
  "kw": "laptop",
  "page": 1
}

同时会附带这些 Header:

  • X-Timestamp
  • X-Nonce
  • X-Sign
  • Authorization

服务端验签规则为:

sign = SHA256(
  method + "\n" +
  path + "\n" +
  ts + "\n" +
  nonce + "\n" +
  canonicalBody + "\n" +
  token + "\n" +
  secret
)

其中:

  • canonicalBody 是按 key 排序后的 JSON 字符串
  • tokenAuthorization: Bearer xxx 中取出
  • secret 是前端代码里某个常量

这个规则在真实站点里可能更复杂,但分析方式基本类似。


第一步:浏览器抓包,先确认“有什么”

你在浏览器 Network 面板里,至少要记录这些信息:

1. 请求基础信息

  • 请求方法:POST
  • 请求路径:/api/search
  • Host
  • Query 参数
  • 请求体原始内容

2. Header 重点字段

重点盯这些名字:

  • sign
  • x-sign
  • authorization
  • x-timestamp
  • nonce
  • x-request-id

3. 响应错误信息

后端报错经常会给你线索,比如:

  • signature expired
  • invalid nonce
  • invalid body
  • sign verify failed

这些错误对排查非常有价值,不要只盯状态码。


第二步:在前端代码里定位签名生成点

搜索优先级建议

在 Sources 里优先搜索:

  1. 参数名:X-Signsign
  2. 时间戳字段:X-Timestamp
  3. 请求路径:/api/search
  4. 常见库调用:axios.createinterceptors.request.use
  5. 哈希关键词:md5sha256CryptoJS

很多站点的签名不是在业务代码里直接生成,而是在请求拦截器里统一注入。

一个典型前端实现

下面是一段模拟的前端签名代码:

const SECRET = "demo_secret_2024";

function canonicalize(obj) {
  const sortedKeys = Object.keys(obj).sort();
  const result = {};
  for (const key of sortedKeys) {
    result[key] = obj[key];
  }
  return JSON.stringify(result);
}

async function sha256(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({ method, path, body, token }) {
  const ts = String(Math.floor(Date.now() / 1000));
  const nonce = Math.random().toString(36).slice(2, 10);
  const canonicalBody = canonicalize(body);
  const raw = [
    method.toUpperCase(),
    path,
    ts,
    nonce,
    canonicalBody,
    token,
    SECRET
  ].join("\n");

  const sign = await sha256(raw);
  return { ts, nonce, sign };
}

如果你在真实项目里看到类似结构,基本就能开始还原了。


第三步:拆出“签名输入串”

这是整个分析中最关键的一步。

很多人会直接盯着哈希结果,但哈希是不可逆的。你真正要还原的是:

哈希之前的原始字符串长什么样。

我自己的经验

如果页面没做严重混淆,我通常优先想办法打印中间值:

  • 在 DevTools 中打断点
  • 覆盖函数返回值
  • 在控制台 patch 原函数
  • 直接查看请求拦截器的入参和出参

你一旦拿到原始输入串,剩下基本就是体力活。

示例:原始拼接串

比如某次请求实际拼接的是:

POST
/api/search
1720000000
ab12cd34
{"kw":"laptop","page":1}
user-token-001
demo_secret_2024

那你只要保证脚本里生成的字符串和它完全一致,签名就能对上。

这里的“完全一致”包括:

  • 换行符是否是 \n
  • JSON 中字段顺序
  • 布尔值/数字/空值格式
  • 是否多了空格
  • 路径是否包含域名
  • 时间戳单位是秒还是毫秒

第四步:本地搭建一个可运行案例

为了把过程讲透,我们先写一个模拟服务端,再写客户端复现代码。


实战代码:Node.js 模拟服务端

保存为 server.js

const express = require("express");
const crypto = require("crypto");

const app = express();
app.use(express.json());

const SECRET = "demo_secret_2024";

function canonicalize(obj) {
  const sortedKeys = Object.keys(obj).sort();
  const result = {};
  for (const key of sortedKeys) {
    result[key] = obj[key];
  }
  return JSON.stringify(result);
}

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

function verifySign(req) {
  const ts = req.header("X-Timestamp");
  const nonce = req.header("X-Nonce");
  const sign = req.header("X-Sign");
  const auth = req.header("Authorization") || "";
  const token = auth.replace(/^Bearer\s+/i, "");

  if (!ts || !nonce || !sign || !token) {
    return { ok: false, msg: "missing required headers" };
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(ts)) > 300) {
    return { ok: false, msg: "signature expired" };
  }

  const canonicalBody = canonicalize(req.body || {});
  const raw = [
    req.method.toUpperCase(),
    req.path,
    ts,
    nonce,
    canonicalBody,
    token,
    SECRET
  ].join("\n");

  const expected = sha256(raw);
  if (expected !== sign) {
    return {
      ok: false,
      msg: "sign verify failed",
      debug: { raw, expected, sign }
    };
  }

  return { ok: true };
}

app.post("/api/search", (req, res) => {
  const result = verifySign(req);
  if (!result.ok) {
    return res.status(403).json(result);
  }

  const { kw, page } = req.body;
  res.json({
    code: 0,
    data: {
      list: [
        { id: 1, title: `${kw}-result-${page}-1` },
        { id: 2, title: `${kw}-result-${page}-2` }
      ]
    }
  });
});

app.listen(3000, () => {
  console.log("server running at http://localhost:3000");
});

安装依赖并启动:

npm init -y
npm install express
node server.js

实战代码:Node.js 客户端复现签名请求

保存为 client.js

const crypto = require("crypto");

const SECRET = "demo_secret_2024";

function canonicalize(obj) {
  const sortedKeys = Object.keys(obj).sort();
  const result = {};
  for (const key of sortedKeys) {
    result[key] = obj[key];
  }
  return JSON.stringify(result);
}

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

function buildHeaders({ method, path, body, token }) {
  const ts = String(Math.floor(Date.now() / 1000));
  const nonce = Math.random().toString(36).slice(2, 10);
  const canonicalBody = canonicalize(body);

  const raw = [
    method.toUpperCase(),
    path,
    ts,
    nonce,
    canonicalBody,
    token,
    SECRET
  ].join("\n");

  const sign = sha256(raw);

  return {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`,
    "X-Timestamp": ts,
    "X-Nonce": nonce,
    "X-Sign": sign
  };
}

async function main() {
  const method = "POST";
  const path = "/api/search";
  const body = { kw: "laptop", page: 1 };
  const token = "user-token-001";

  const headers = buildHeaders({ method, path, body, token });

  const resp = await fetch(`http://localhost:3000${path}`, {
    method,
    headers,
    body: JSON.stringify(body)
  });

  const data = await resp.json();
  console.log("status:", resp.status);
  console.log(data);
}

main().catch(console.error);

执行:

node client.js

如果一切正常,你会拿到成功响应。


用 Python 复现同一套签名

真实工作里,浏览器分析完之后,很多人会用 Python 落地脚本。这里给一个可运行版本。

保存为 client.py

import time
import json
import hashlib
import random
import string
import requests

SECRET = "demo_secret_2024"

def canonicalize(obj: dict) -> str:
    ordered = {k: obj[k] for k in sorted(obj.keys())}
    return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)

def sha256(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def build_headers(method: str, path: str, body: dict, token: str) -> dict:
    ts = str(int(time.time()))
    nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
    canonical_body = canonicalize(body)

    raw = "\n".join([
        method.upper(),
        path,
        ts,
        nonce,
        canonical_body,
        token,
        SECRET
    ])

    sign = sha256(raw)

    return {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {token}",
        "X-Timestamp": ts,
        "X-Nonce": nonce,
        "X-Sign": sign
    }

def main():
    method = "POST"
    path = "/api/search"
    body = {"kw": "laptop", "page": 1}
    token = "user-token-001"

    headers = build_headers(method, path, body, token)
    resp = requests.post(
        f"http://localhost:3000{path}",
        headers=headers,
        data=json.dumps(body, separators=(",", ":"), ensure_ascii=False)
    )

    print("status:", resp.status_code)
    print(resp.text)

if __name__ == "__main__":
    main()

安装依赖并运行:

pip install requests
python client.py

逐步验证清单

如果你已经拿到某个真实站点的请求,但暂时复现不出来,我建议按这个清单一项项验证。

flowchart LR
    A[请求失败] --> B{时间戳正确?}
    B -- 否 --> B1[检查秒/毫秒与时区]
    B -- 是 --> C{请求体序列化一致?}
    C -- 否 --> C1[统一 JSON key 顺序与空格]
    C -- 是 --> D{签名原串一致?}
    D -- 否 --> D1[打印 raw 串逐字符比对]
    D -- 是 --> E{Token/Cookie 正确?}
    E -- 否 --> E1[补齐登录态]
    E -- 是 --> F{请求头完整?}
    F -- 否 --> F1[补齐拦截器注入字段]
    F -- 是 --> G[检查算法与编码细节]

这张图很重要,因为很多问题不是算法错,而是“前置条件没满足”。


常见坑与排查

这一节我会讲一些非常常见、而且足够折磨人的问题。


坑 1:JSON 看起来一样,签名却不一样

比如:

{"kw":"laptop","page":1}

和:

{"page":1,"kw":"laptop"}

从业务上看没区别,但如果签名对原始字符串做摘要,顺序不同,结果就完全不同。

排查方式

  • 前端里找到真正序列化 body 的函数
  • 看它有没有 sort()
  • Python 里使用 separators=(",", ":")
  • 避免默认序列化带空格

坑 2:时间戳单位错了

有的接口要秒:

1720000000

有的要毫秒:

1720000000123

你如果把毫秒当秒传,后端一般会报过期;反过来也一样。

排查方式

看浏览器中真实值长度:

  • 10 位,大概率秒
  • 13 位,大概率毫秒

坑 3:路径参与签名,但你用了完整 URL

很多签名规则只用:

/api/search

而不是:

https://example.com/api/search

这个细节很容易忽略。

排查方式

看前端传给签名函数的参数到底是 urlpathname,还是拼接后的完整地址。


坑 4:Authorization 看似没参与,实际上参与了

不少接口虽然把 token 放在 Header,但签名函数会从 Header 再拿一遍 token 参与计算。

排查方式

检查:

  • Authorization
  • Cookie 中的 session
  • LocalStorage 中的 token
  • 请求拦截器是否把 token 传给签名函数

坑 5:浏览器请求成功,Python 仍失败

最常见不是算法问题,而是请求发送方式不一致。

比如:

  • 浏览器发的是 application/json
  • 你 Python 发成了 form-data
  • 或者 requests.post(json=body) 与前端实际发送格式不同

排查方式

和浏览器逐项比对:

  • Content-Type
  • body 原始字节
  • Cookie
  • Header 中的自定义字段
  • 是否走了重定向

坑 6:签名算法定位对了,但值还是不一致

这时要怀疑“输入字符串中含有你没注意到的预处理”。

真实项目里常见:

  • encodeURIComponent
  • 再 Base64
  • 再做 SHA256
  • 或字符串先转小写/大写
  • 数字字段被转成字符串
  • 空值字段被过滤掉

排查方式

最有效的方法是:
在浏览器中打印每一步中间结果,而不是只看最终 sign。


进阶定位技巧

如果站点代码是打包压缩过的,可以这样拆。

技巧 1:从请求拦截器逆推

很多项目使用 axios:

axios.interceptors.request.use((config) => {
  // 注入 ts, nonce, sign
  return config;
});

你可以直接在这里下断点,观察:

  • config.url
  • config.data
  • config.headers
  • 签名函数调用位置

这通常比在全局搜 sha256 更快。

技巧 2:hook 哈希函数

如果站点使用 CryptoJS.SHA256 或自定义 md5 函数,你可以在控制台临时 hook:

const oldDigest = crypto.subtle.digest;
crypto.subtle.digest = async function(algo, data) {
  const text = new TextDecoder().decode(data);
  console.log("digest input:", algo, text);
  return oldDigest.call(this, algo, data);
};

这样就能看到哈希前的输入。

注意:这类 hook 在某些场景可能会被 CSP、只读属性或运行时封装影响,不一定总能成功,但值得试。

技巧 3:搜索固定常量

如果你发现 sign 总和某个固定盐值有关,可以搜索:

  • 特定字符串片段
  • Header 名字
  • API path
  • secret 相关常量

很多时候你找不到函数名,但能通过常量把代码块揪出来。


安全/性能最佳实践

这部分不只是“怎么复现”,还包括“怎么做得更稳”。


1. 不要把单次抓包结果当成最终答案

抓到一个请求只能说明那一次成功了,不代表规则已掌握。

更靠谱的做法是做两次或三次对比,观察:

  • 哪些字段固定不变
  • 哪些字段每次都变
  • 哪些字段只在 body 改变时变化

2. 建立“最小可复现脚本”

我强烈建议在分析早期就写一个最小脚本,只做三件事:

  1. 生成参数
  2. 发请求
  3. 打印原始签名串和响应

不要一开始就堆业务逻辑。先把签名跑通,再扩展。

3. 对中间结果做日志留存

建议至少打印:

  • 参与签名的原始字符串
  • 最终 sign
  • 实际发送的 body
  • 关键 Header

这样以后接口变更时,你能快速知道变的是哪一层。

4. 控制请求频率,避免误伤服务

即便是测试接口复现,也不要高频轰炸:

  • 增加重试间隔
  • 做并发限制
  • 遵守目标系统的访问边界和合规要求

5. 注意敏感信息保护

在日志和代码仓库中,不要直接暴露:

  • token
  • cookie
  • 用户标识
  • 真实 secret
  • 生产接口地址

建议:

  • 用环境变量存储凭据
  • 打日志时做脱敏
  • 区分测试与生产配置

一个实用的分析模板

当你面对新的目标站点时,可以按下面这个表来记录,效率会高很多。

项目记录内容
请求方法POST / GET
请求路径/api/xxx
请求体格式JSON / Form / Query
动态字段tsnoncesign
登录态来源Cookie / Authorization
签名算法SHA256 / MD5 / 未知
拼接顺序method + path + …
编码处理URL encode / Base64 / Hex
校验窗口5 分钟 / 60 秒
可复现状态已跑通 / 待确认

这类记录很朴素,但在多次迭代时特别有用。


边界条件与合规提醒

这篇文章讨论的是签名分析与参数还原的方法论,适用于:

  • 自有系统联调排障
  • 授权测试环境分析
  • 安全研究与防护验证
  • 学习前端请求链路与接口安全机制

不适用于未授权的数据抓取、绕过访问控制或违反目标系统使用规则的场景。
这点非常重要,技术能力和使用边界要一起建立。


总结

从浏览器抓包到参数还原,真正关键的不是“会不会复制请求”,而是能不能建立这套分析闭环:

  1. 抓到完整请求
  2. 定位动态参数来源
  3. 还原签名输入串
  4. 在脚本中一致地重建请求
  5. 通过逐项比对修正差异

你可以把这篇文章中的方法总结成一句话:

Web 接口签名分析,本质上是在找“请求生成过程”,而不是在猜“参数值”。

如果你已经是中级开发者,我建议你在真实工作里优先养成两个习惯:

  • 每次都打印签名原串,不只打印 sign
  • 每次都先做最小可复现,再接业务脚本

这两个习惯,会让你少走很多弯路。我自己后来做这类问题时,基本都靠这套方法稳定落地。

如果你现在手头正好有一个“浏览器能成功、脚本却失败”的接口,不妨按本文的流程重走一遍。很多时候,问题不在算法本身,而是在某个你以为“不重要”的细节上。


分享到:

上一篇
《从 Prompt 到 Pipeline:中级开发者构建可落地大模型应用的工程实践指南》
下一篇
《大模型推理性能实战优化:从 KV Cache、量化到批处理调度的工程方法》