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

《从抓包到补环境:中级开发者实战 Web 逆向中的签名参数还原与请求重放》

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

从抓包到补环境:中级开发者实战 Web 逆向中的签名参数还原与请求重放

很多中级开发者第一次接触 Web 逆向时,都会卡在同一个点:接口明明抓到了,请求参数也看到了,但一重放就报错

常见报错包括:

  • sign invalid
  • timestamp expired
  • request forbidden
  • illegal token
  • env check failed

这篇文章我想用一种“带你走一遍”的方式,讲清楚一个很核心的实战路径:

先抓包定位关键请求,再分析签名生成链路,最后通过补环境或脱离浏览器重放请求。

重点不是讲某个站点的细节,而是总结一套中级开发者能真正落地的方法论和代码框架。


背景与问题

在现代 Web 应用里,前端请求通常不只是简单地带上查询参数。服务端为了防刷、鉴权、风控,会在请求中加入一些动态参数,比如:

  • 时间戳 ts
  • 随机串 nonce
  • 摘要签名 sign
  • 设备信息指纹 fingerprint
  • token / session
  • 某些加密后的业务参数

这就导致一个典型现象:

  1. 你用浏览器打开页面;
  2. 在 DevTools 或抓包工具里看到某个接口;
  3. 复制成 cURL 或 Python 请求;
  4. 结果服务端不认。

根因通常不是“接口地址错了”,而是以下几类问题:

  • 签名参数依赖当前时间
  • 签名串拼接顺序有要求
  • 签名算法藏在前端 JS 中
  • 执行过程中读取了浏览器环境对象
  • 请求头、Cookie、Referer、Origin 参与了签名
  • 某些值来自异步初始化流程,不在单个函数参数里

我自己踩坑最多的一次,不是算法难,而是少补了一个 window.navigator.userAgent,结果签名函数能跑,但生成值始终不对。这个阶段很容易误判成“算法还没还原”。

所以做 Web 逆向,不能只盯着某个加密函数,要看完整链路。


前置知识与环境准备

如果你已经会基本抓包,可以直接跳到实战部分;如果没有,建议先确认下面这些准备项。

你需要具备的基础

  • 会用浏览器 DevTools 看 Network / Sources
  • 能读懂基本 JavaScript
  • 知道 Cookie、Header、XHR / Fetch 的区别
  • 能用 Python 发 HTTP 请求
  • 理解哈希、HMAC、AES 这些常见术语,但不要求精通密码学

建议工具

  • 浏览器 DevTools
  • Charles / Fiddler / mitmproxy 任选其一
  • Node.js
  • Python 3
  • 一个能全局搜索 JS 的编辑器,比如 VS Code

本文演示环境

为了让代码可运行,我用一个最小可复现案例来模拟真实站点行为:

  • 前端会生成 tsnoncesign
  • sign 依赖参数排序、请求路径和一个“浏览器环境值”
  • 服务端会校验签名是否正确

这样你能在本地把思路跑通,再迁移到真实目标。


核心原理

先讲清楚问题模型,再写代码。

1. 签名参数通常由什么组成

在大量 Web 接口里,签名大致遵循这个模式:

sign = hash(请求路径 + 排序后的业务参数 + 时间戳 + nonce + secret/env)

也就是说,一个签名函数往往包含四层输入:

  • 固定输入:接口路径、方法名
  • 业务输入:分页参数、查询词、ID 等
  • 动态输入:时间戳、随机串
  • 环境输入:UA、screen、token、cookie、本地存储值

2. 为什么“复制请求”经常失败

因为你复制的是“结果”,不是“生成过程”。

例如你抓到这样的请求:

POST /api/search
ts=1684290000
nonce=ab12cd34
sign=8f5a...

几秒钟后再发一次,ts 已经过期;或者服务端会重新根据请求体和当前 Cookie 计算签名,你之前抓到的 sign 自然就失效了。

3. Web 逆向里常见的两条路径

路径 A:直接调用前端原始签名函数

适合情况:

  • 已经定位到签名函数
  • 混淆不重
  • 环境依赖少
  • 可以在浏览器控制台或 Node 中补齐对象

优点:

  • 还原速度快
  • 逻辑偏稳定
  • 不容易拼错细节

缺点:

  • 依赖环境补全
  • 前端升级后容易失效

路径 B:重写签名逻辑

适合情况:

  • 算法明确,例如 MD5/HMAC/SHA256
  • 参数拼接规则已搞清楚
  • 原函数混淆严重、不好直接跑

优点:

  • 代码更干净
  • 更适合自动化

缺点:

  • 容易漏细节
  • 一旦顺序、编码、空值处理错了,就全错

从抓包到还原的整体流程

下面这个流程图,基本覆盖了中级开发者在实战里的主线。

flowchart TD
    A[抓包定位目标接口] --> B[确认请求方法/头/体/响应]
    B --> C[识别动态参数 ts nonce sign]
    C --> D[全局搜索 sign/ts/nonce 关键词]
    D --> E[定位调用栈与签名函数]
    E --> F{依赖浏览器环境吗}
    F -- 否 --> G[直接抽离函数到 Node]
    F -- 是 --> H[补 window/document/navigator 等环境]
    G --> I[生成签名并重放请求]
    H --> I
    I --> J{返回正常吗}
    J -- 否 --> K[排查编码/排序/时间漂移/请求头]
    J -- 是 --> L[封装自动化脚本]

抓包分析:先确定你要逆向的是“哪一层”

很多人一上来就搜索 sign,其实容易迷路。更稳妥的方法,是先在 Network 面板里做三件事。

第一步:锁定真正的业务请求

不要被这些内容干扰:

  • 静态资源请求
  • 埋点上报
  • 预加载接口
  • 轮询保活接口

重点找:

  • 触发某个明确业务动作后才发出的请求
  • 响应里确实包含你想拿的数据
  • 每次操作时 signts 发生变化

第二步:对比两次请求差异

同样的操作做两遍,比较:

  • Query String 是否变了
  • Request Payload 是否变了
  • Header 是否多了动态字段
  • Cookie 是否更新
  • 请求顺序是否有前置依赖

如果两次请求中,只有 tsnoncesign 不同,那说明逆向范围相对清晰。

第三步:看 Initiator / 调用栈

浏览器 DevTools 里经常能看到请求由哪段 JS 发起。继续点进去,往往能看到:

  • axios/fetch 封装
  • 请求拦截器
  • 统一签名函数
  • 参数序列化逻辑

这一步特别关键,因为很多站点的签名不是在业务页面代码里生成,而是在请求拦截器里统一注入。


核心原理拆解:签名生成链路

下面用一个时序图表示从用户点击到服务端校验签名的过程。

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

    U->>P: 点击搜索/翻页
    P->>S: 传入 path + params
    S->>B: 读取 UA/token/时间戳/随机值
    B-->>S: 返回环境数据
    S-->>P: 生成 sign/ts/nonce
    P->>API: 发送带签名请求
    API->>API: 按相同规则校验签名
    API-->>P: 返回业务数据或错误

这里最容易被忽略的是:

签名函数不一定只依赖它的入参,它可能隐式读取环境。

例如:

  • localStorage.getItem("token")
  • navigator.userAgent
  • document.cookie
  • location.pathname
  • window._appConfig.secret

如果你只把某个函数复制出来运行,多半会报错,或者运行了但结果不对。


实战代码(可运行)

下面我们构造一个本地可运行的示例,包括:

  1. 一个模拟服务端校验签名的 Flask 服务
  2. 一个模拟前端签名逻辑的 Node 脚本
  3. 一个 Python 重放请求脚本

这样你能完整体验“抓包参数 → 还原签名 → 请求重放”的过程。


第 1 步:模拟服务端

创建 server.py

from flask import Flask, request, jsonify
import hashlib
import time

app = Flask(__name__)

SERVER_SECRET = "my_server_secret"
ALLOWED_TIME_DRIFT = 10  # 秒


def canonical_string(path: str, params: dict, ts: str, nonce: str, ua: str) -> str:
    filtered = {k: v for k, v in params.items() if k not in ("sign",)}
    sorted_items = sorted(filtered.items(), key=lambda x: x[0])
    query = "&".join(f"{k}={v}" for k, v in sorted_items)
    return f"{path}|{query}|{ts}|{nonce}|{ua}|{SERVER_SECRET}"


def make_sign(path: str, params: dict, ts: str, nonce: str, ua: str) -> str:
    raw = canonical_string(path, params, ts, nonce, ua)
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


@app.route("/api/search", methods=["GET"])
def search():
    args = request.args.to_dict()
    ts = args.get("ts", "")
    nonce = args.get("nonce", "")
    sign = args.get("sign", "")
    keyword = args.get("keyword", "")
    page = args.get("page", "1")
    ua = request.headers.get("User-Agent", "")

    if not ts or not nonce or not sign:
        return jsonify({"code": 4001, "msg": "missing sign params"}), 400

    try:
        ts_int = int(ts)
    except ValueError:
        return jsonify({"code": 4002, "msg": "invalid ts"}), 400

    now = int(time.time())
    if abs(now - ts_int) > ALLOWED_TIME_DRIFT:
        return jsonify({"code": 4003, "msg": "timestamp expired"}), 403

    expected = make_sign(
        "/api/search",
        {"keyword": keyword, "page": page, "ts": ts, "nonce": nonce},
        ts,
        nonce,
        ua
    )

    if sign != expected:
        return jsonify({"code": 4004, "msg": "sign invalid"}), 403

    return jsonify({
        "code": 0,
        "msg": "ok",
        "data": {
            "keyword": keyword,
            "page": int(page),
            "items": [
                {"id": 1, "title": f"{keyword}-result-1"},
                {"id": 2, "title": f"{keyword}-result-2"}
            ]
        }
    })


if __name__ == "__main__":
    app.run(port=5000, debug=True)

安装并运行:

pip install flask
python server.py

第 2 步:模拟前端签名逻辑

创建 sign.js

const crypto = require("crypto");

function getBrowserEnv() {
  return {
    navigator: {
      userAgent: "Mozilla/5.0 DemoBrowser/1.0"
    }
  };
}

function sortParams(params) {
  return Object.keys(params)
    .sort()
    .map((k) => `${k}=${params[k]}`)
    .join("&");
}

function makeSign(path, params) {
  const env = getBrowserEnv();
  const ua = env.navigator.userAgent;
  const secret = "my_server_secret";
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = Math.random().toString(36).slice(2, 10);

  const fullParams = {
    ...params,
    ts,
    nonce
  };

  const query = sortParams(fullParams);
  const raw = `${path}|${query}|${ts}|${nonce}|${ua}|${secret}`;
  const sign = crypto.createHash("sha256").update(raw, "utf8").digest("hex");

  return {
    ...fullParams,
    sign,
    ua
  };
}

if (require.main === module) {
  const result = makeSign("/api/search", {
    keyword: "python",
    page: "1"
  });
  console.log(JSON.stringify(result, null, 2));
}

module.exports = {
  makeSign
};

运行:

node sign.js

你会看到类似输出:

{
  "keyword": "python",
  "page": "1",
  "ts": "1711111111",
  "nonce": "abc123ef",
  "sign": "xxxx...",
  "ua": "Mozilla/5.0 DemoBrowser/1.0"
}

第 3 步:请求重放

创建 replay.py

import subprocess
import json
import requests


def get_signed_params():
    result = subprocess.check_output(["node", "sign.js"], text=True)
    return json.loads(result)


def replay():
    signed = get_signed_params()

    params = {
        "keyword": signed["keyword"],
        "page": signed["page"],
        "ts": signed["ts"],
        "nonce": signed["nonce"],
        "sign": signed["sign"],
    }

    headers = {
        "User-Agent": signed["ua"]
    }

    resp = requests.get("http://127.0.0.1:5000/api/search", params=params, headers=headers, timeout=10)
    print("status:", resp.status_code)
    print(resp.text)


if __name__ == "__main__":
    replay()

安装依赖并运行:

pip install requests
python replay.py

返回正常时应该类似:

{
  "code": 0,
  "msg": "ok",
  "data": {
    "keyword": "python",
    "page": 1,
    "items": [
      {"id": 1, "title": "python-result-1"},
      {"id": 2, "title": "python-result-2"}
    ]
  }
}

到这里,一个完整闭环就跑通了。


第 4 步:从“可运行 demo”映射到真实站点

真实站点当然不会把签名逻辑写得这么直白,但你可以按下面这套方式迁移。

1. 找关键词

在 Sources 里全局搜索:

  • sign
  • sha256
  • md5
  • nonce
  • timestamp
  • interceptors.request
  • headers.common
  • fetch( / axios.create(

如果站点经过混淆,关键词不一定直接出现,这时可以反向找:

  • 请求 URL 片段
  • 某个固定请求头名
  • 响应报错文案,如 sign invalid

2. 断点看实参

比起静态硬读代码,我更推荐直接在可疑函数下断点,看:

  • 入参值
  • 局部变量
  • 最终拼接字符串
  • 返回值

如果能看到“签名原文串”,难度会瞬间下降一个量级。

3. 优先抽离“最小签名路径”

不要试图一口气搬完整前端工程。更好的方法是:

  • 先把签名函数所在模块单独提出来
  • 补齐它真正依赖的最少环境
  • 保证能在 Node 中返回正确签名
  • 再接入自动化请求

这一步的关键不是“代码多完整”,而是“依赖足够小”。


补环境:什么时候必须做

有些前端签名逻辑在浏览器里能跑,复制到 Node 里就报这些错:

  • window is not defined
  • document is not defined
  • navigator is not defined
  • location is not defined
  • localStorage is not defined

这就是典型的“需要补环境”。

常见依赖对象

对象常见用途
window全局挂载配置、SDK
documentCookie、DOM 属性
navigatorUA、语言、平台
location当前路径、域名
screen分辨率、颜色深度
localStoragetoken、设备 ID
sessionStorage会话态参数

一个最小补环境示例

创建 env_sign.js

const crypto = require("crypto");

global.window = {};
global.document = {
  cookie: "sessionid=demo123; token=abcxyz"
};
global.navigator = {
  userAgent: "Mozilla/5.0 DemoBrowser/1.0",
  language: "zh-CN"
};
global.location = {
  pathname: "/search",
  origin: "https://example.com"
};
global.localStorage = {
  store: {
    token: "abcxyz"
  },
  getItem(key) {
    return this.store[key] || null;
  }
};

function getTokenFromCookie() {
  const cookie = document.cookie || "";
  const match = cookie.match(/token=([^;]+)/);
  return match ? match[1] : "";
}

function makeSignWithEnv(path, params) {
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = "fixed1234";
  const token = localStorage.getItem("token") || getTokenFromCookie();
  const ua = navigator.userAgent;

  const fullParams = { ...params, ts, nonce };
  const query = Object.keys(fullParams)
    .sort()
    .map((k) => `${k}=${fullParams[k]}`)
    .join("&");

  const raw = `${path}|${query}|${token}|${ua}`;
  const sign = crypto.createHash("md5").update(raw, "utf8").digest("hex");

  return {
    ...fullParams,
    sign
  };
}

console.log(makeSignWithEnv("/api/search", { keyword: "node", page: "2" }));

这个例子虽然简单,但说明了一件实战里很重要的事:

补环境不是为了“伪造浏览器”,而是为了让目标函数顺利拿到它依赖的值。


补环境依赖关系图

下面这张图能帮助你判断:签名不对时,应该补哪一类依赖。

classDiagram
    class SignFunction {
        +makeSign(path, params)
    }
    class Params {
        +keyword
        +page
        +ts
        +nonce
    }
    class BrowserEnv {
        +navigator.userAgent
        +document.cookie
        +location.pathname
        +localStorage.getItem()
    }
    class AppConfig {
        +secret
        +appId
    }

    SignFunction --> Params
    SignFunction --> BrowserEnv
    SignFunction --> AppConfig

逐步验证清单

真实项目里,我很少一上来就“全量重放”。更稳妥的方法是逐项验证。

验证 1:确认是否为纯摘要算法

先观察输出特征:

  • 32 位十六进制:常见 MD5
  • 40 位:常见 SHA1
  • 64 位:常见 SHA256
  • Base64:可能是加密结果或 HMAC 输出

这只是经验判断,不绝对,但能缩小范围。

验证 2:确认参数排序规则

重点看是否:

  • 按 key 字典序排序
  • 排除空值
  • 排除 sign 本身
  • 对数组、对象做 JSON 序列化
  • URL 编码前签名还是编码后签名

这一点错了,结果几乎必错。

验证 3:确认时间戳单位

常见有三种:

  • 秒:1684290000
  • 毫秒:1684290000123
  • 自定义格式:如 ISO 时间、格式化字符串

别小看这个问题,我见过很多签名算法完全还原了,最后只是秒和毫秒搞反。

验证 4:确认是否参与 Header

有些签名虽然放在 Query 里,但计算时还包含:

  • User-Agent
  • Origin
  • Referer
  • X-Requested-With
  • 自定义 header,比如 x-app-id

验证 5:确认是否有前置接口

比如:

  • 先请求配置接口拿动态公钥
  • 先执行 challenge 获取一次性 token
  • 先加载某段 wasm 再算签名

如果漏了前置步骤,你看到的签名函数可能只是最后一层。


常见坑与排查

这一节我尽量讲得“像排错现场”一点。

坑 1:签名函数找对了,但结果总差一点

表现:

  • 自己算出来的签名和浏览器里的只差一点点
  • 每次都不一样,但规律类似

重点排查:

  • 参数排序是否一致
  • 是否把 sign 自己也算进去了
  • 空字符串参数是否参与
  • 数字和字符串是否混用
  • URL 编码顺序是否正确

例如这两种字符串,看起来差不多,结果完全不同:

page=1&keyword=python
keyword=python&page=1

坑 2:Node 能跑,签名不对

表现:

  • 没报错
  • 输出也有值
  • 但服务端一直说 sign invalid

重点排查环境值:

  • navigator.userAgent 是否一致
  • Cookie 是否完整
  • localStorage 中的 token 是否同步
  • 当前路径 location.pathname 是否一致

这类问题最隐蔽,因为函数“能运行”会让人误以为逻辑已经对了。

坑 3:请求重放时偶发成功、偶发失败

表现:

  • 有时候能过,有时候超时或验签失败

重点排查:

  • 时间戳窗口太短
  • 签名生成后到发请求间隔太久
  • 并发下 nonce 重复
  • 复用了过期 Cookie / token

建议把“生成签名”和“发送请求”尽量放到同一个短链路里。

坑 4:浏览器里有值,脚本里没有值

表现:

  • 浏览器调试时能看到 token
  • 脚本执行时 undefined

原因通常是:

  • 值在闭包里,不在全局
  • 异步初始化尚未完成
  • webpack 模块作用域隔离
  • 被 getter 动态计算

排查方法:

  • 在调用点断点,不只看定义点
  • 观察运行时对象而不是源代码猜测
  • 直接打印最终参与签名的原文串

坑 5:请求参数正确,但还是被风控

表现:

  • 签名校验通过了
  • 仍然返回空数据、验证码、403

这说明问题已经不只是签名,还可能涉及:

  • IP 风控
  • 请求频率限制
  • 设备指纹
  • TLS 指纹
  • 行为轨迹校验

这时候要明确边界:签名还原成功,不等于整个风控链路都绕过了。


安全/性能最佳实践

这一部分很重要。能跑只是第一步,稳定、可控、合规才是长期方案。

1. 不要硬编码敏感信息到仓库

例如:

  • Cookie
  • token
  • secret
  • 代理账号密码

建议:

  • 用环境变量读取
  • 本地 .env 管理
  • 不要提交到 Git

示例:

import os

API_TOKEN = os.getenv("API_TOKEN", "")

2. 将签名逻辑和请求逻辑解耦

不要把所有东西揉在一个函数里。建议分层:

  • signer:只负责签名
  • client:只负责发请求
  • parser:只负责解析数据

这样当签名规则变化时,只改一个点。

3. 控制重放频率

即使你成功还原签名,也不要高频并发重放。否则很容易触发:

  • 封 IP
  • 封账号
  • 动态升级风控

建议:

  • 加随机 sleep
  • 做指数退避重试
  • 控制并发数

示例:

import time
import random

time.sleep(random.uniform(0.5, 1.5))

4. 给关键步骤打日志

实战里最值得记录的不是“请求成功了”,而是这些内容:

  • 签名前原文串
  • 最终 sign
  • 请求时间
  • 响应状态码
  • 响应错误码
  • 关键 header

这样出现问题时能快速对比浏览器和脚本的差异。

5. 优先最小补环境,避免引入整套浏览器模拟

jsdom、无头浏览器当然能用,但成本较高。对很多签名场景来说,先试:

  • 手工 mock 必要对象
  • 只补被访问到的属性
  • 把依赖压缩到最少

这样更轻、更稳定,也更容易维护。

6. 明确合规边界

Web 逆向和请求重放必须遵守目标系统的使用条款、法律法规与授权边界。尤其不要用于:

  • 未经授权的数据抓取
  • 绕过访问控制
  • 规避付费限制
  • 大规模攻击性请求

技术上能做到,不代表场景上就合适。这个边界要非常清楚。


一个更实用的项目结构建议

如果你准备把这类逻辑做成长期维护的工具,建议项目结构像这样:

project/
├── signer/
│   ├── env.js
│   ├── sign.js
│   └── utils.js
├── client/
│   ├── api.py
│   └── session.py
├── tests/
│   ├── test_sign.py
│   └── fixtures.json
├── server.py
└── replay.py

好处是:

  • 签名逻辑升级时更容易定位
  • 测试用例可复用
  • 浏览器抓到的新样本能直接回归验证

一个简单的回归测试思路

签名逻辑最怕“前端偷偷升级”。建议保留一组样本做回归测试。

例如记录:

  • path
  • params
  • ua
  • 预期 sign

这样每次改动补环境或重构算法,都能快速确认有没有偏差。

import hashlib


def make_sign(path, query, ts, nonce, ua, secret):
    raw = f"{path}|{query}|{ts}|{nonce}|{ua}|{secret}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def test_sign():
    path = "/api/search"
    query = "keyword=python&nonce=abc123&page=1&ts=1684290000"
    ts = "1684290000"
    nonce = "abc123"
    ua = "Mozilla/5.0 DemoBrowser/1.0"
    secret = "my_server_secret"

    expected = hashlib.sha256(
        f"{path}|{query}|{ts}|{nonce}|{ua}|{secret}".encode("utf-8")
    ).hexdigest()

    assert make_sign(path, query, ts, nonce, ua, secret) == expected

虽然这个测试很简单,但在维护中非常值钱。


总结

把 Web 逆向里的“签名参数还原与请求重放”做成稳定方案,我建议你记住下面这条主线:

  1. 先抓包,确认真正目标请求
  2. 找动态参数,识别 ts / nonce / sign
  3. 顺着调用栈找到签名注入点
  4. 判断是直接抽离,还是需要补环境
  5. 最小化复现签名函数
  6. 把签名生成和请求重放连成短链路
  7. 用日志与回归测试保证稳定性

如果只给一个最实用的建议,那就是:

不要急着“重写算法”,先尽量“还原运行现场”。

很多时候,真正难的不是加密算法本身,而是它依赖的上下文。把上下文找全了,问题就会从“逆向很玄学”变成“普通工程调试”。

最后再强调一下边界条件:

  • 这套方法适合中级开发者做接口分析、自动化测试、协议调试、授权范围内的研究;
  • 如果目标站点引入了更重的风控,如设备指纹、行为校验、WASM、TLS 指纹,那么签名还原只是第一步;
  • 在任何真实场景中,都应确保你的行为合法、合规且获得授权。

如果你现在正卡在“抓到了请求但重放失败”的阶段,建议就按本文 demo 的结构,先把本地最小案例跑通。等你把这条链路真正打透,再回到真实站点,思路会清晰很多。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建提速到安全优化的完整方案》
下一篇
《Docker 镜像瘦身与构建加速实战:多阶段构建、缓存优化及安全扫描全流程指南》