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

《从浏览器指纹到请求签名:一次 Web 逆向中级实战拆解与自动化复现》

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

从浏览器指纹到请求签名:一次 Web 逆向中级实战拆解与自动化复现

很多人学 Web 逆向时,卡住的不是“怎么抓包”,而是抓到包之后发现:参数看起来都在,服务端却还是判你无效。这类问题往往不只是一个 sign 字段没算对,而是背后还有一整条链路:

  • 浏览器环境采集
  • 指纹生成
  • 动态 token 下发
  • 请求签名
  • 风控校验
  • 时序一致性检查

这篇文章我想用一个中级实战视角,带你把这条链路拆开,然后做一个自动化复现。重点不是“某个站点的私有算法”,而是掌握一套可迁移的方法:面对浏览器指纹 + 请求签名的组合型防护,如何系统分析、逐步验证、最后稳定复现。


背景与问题

在实际项目里,我们常见这样的接口防护:

  1. 首次打开页面时,前端 JavaScript 收集浏览器环境信息
  2. 这些信息被整理成指纹串,可能会做哈希或加密
  3. 页面再根据时间戳、路径、body、token、指纹等数据计算请求签名
  4. 服务端验证:
    • 签名是否正确
    • 指纹是否合法
    • 请求顺序是否合理
    • 时间戳是否超窗
    • token 是否与会话匹配

于是你会看到一种典型现象:

  • 直接用 requests 重放失败
  • 复制浏览器 headers 也失败
  • 即使 sign 算出来了,还是 403 / 412 / 业务码异常

这说明问题已经不再是“补个请求头”这么简单,而是进入了“环境 + 算法 + 时序”的综合对抗。

一个典型防护链路

flowchart TD
    A[访问页面] --> B[加载前端JS]
    B --> C[采集浏览器环境]
    C --> D[生成浏览器指纹]
    D --> E[获取动态token]
    E --> F[按规则计算sign]
    F --> G[发起API请求]
    G --> H[服务端校验sign]
    H --> I[服务端校验指纹/时序/token]
    I --> J{是否通过}
    J -- 是 --> K[返回数据]
    J -- 否 --> L[拒绝/降级/验证码]

前置知识

如果你已经会这些内容,阅读会顺很多:

  • Chrome DevTools 基本使用
  • 会看 XHR / Fetch 请求
  • 知道 webpack 打包、source map、混淆变量的常见表现
  • 了解 md5 / sha256 / hmac 这些签名基础
  • 会用 Python 或 Node.js 做接口复现

环境准备

建议准备如下工具:

  • 浏览器:Chrome
  • 抓包与调试:DevTools
  • JavaScript 调试:浏览器 Sources、必要时可用 AST 工具
  • Python 3.10+
  • requests
  • Node.js 16+(当签名逻辑不适合纯 Python 重写时很好用)

安装 Python 依赖:

pip install requests flask

这篇文章里的代码是可运行的教学示例。我不会绑定具体网站,而是模拟一个常见站点防护模型,让你把方法吃透。


核心原理

这一节先把“浏览器指纹”和“请求签名”之间的关系讲清楚。

1. 浏览器指纹是什么

浏览器指纹不是单一字段,而是一组环境特征的组合,比如:

  • userAgent
  • language
  • platform
  • screen 分辨率
  • 时区
  • Canvas / WebGL 特征
  • 插件数量
  • 字体信息
  • navigator.webdriver
  • 鼠标轨迹、事件节奏(更强对抗里会出现)

前端会把这些数据拼成一个稳定字符串,再做摘要,例如:

ua=...|lang=zh-CN|tz=Asia/Shanghai|platform=Win32|screen=1920x1080

然后再做:

  • md5(fingerprint_raw)
  • sha256(...)
  • 或 AES/RSA 包装后上送

2. 请求签名是什么

签名通常是为了防篡改、防重放、防伪造。常见拼接字段有:

  • 请求路径
  • 请求方法
  • 时间戳
  • nonce
  • body 的摘要
  • token
  • 指纹值

例如:

sign = sha256(path + "|" + ts + "|" + nonce + "|" + body_md5 + "|" + fp + "|" + secret)

这个模型很常见,因为它同时绑定了:

  • 请求内容
  • 当前会话
  • 当前环境
  • 时间窗口

3. 为什么“单独重算 sign”还会失败

因为服务端并不只校验 sign,还可能做这些事:

  • fp 是否来自同一会话初始化阶段
  • ts 是否在允许窗口内,比如 5 秒
  • nonce 是否重复
  • 请求头顺序、大小写、内容是否异常
  • token 是否由某个初始化接口下发
  • sign 算法是否依赖某个运行时环境参数

我之前就踩过一个坑:以为签名公式找到了,结果重放还是失败。最后发现 fingerprint 并不是静态生成的,而是依赖页面初始化阶段拿到的 seed。缺了这个 seed,签名永远对不上。


分析思路:不要一上来就抠算法

中级阶段最重要的提升,是从“我去抠一段混淆 JS”切换到“我先建立全局链路图”。

推荐分析顺序

sequenceDiagram
    participant U as 用户/脚本
    participant P as 页面JS
    participant S as 服务端

    U->>P: 打开页面
    P->>P: 收集环境信息
    P->>S: 请求初始化配置/seed/token
    S-->>P: 返回seed/token
    P->>P: 生成fingerprint
    P->>P: 计算sign
    P->>S: 携带fp、ts、nonce、sign请求API
    S-->>U: 返回数据或拒绝

实战里我通常这样做

第一步:先找“谁最早生成了关键参数”

关注这些关键词:

  • sign
  • token
  • fingerprint
  • deviceId
  • nonce
  • timestamp
  • encrypt
  • hash
  • Authorization

如果是打包代码,不一定能直接搜到明文名字,那就从请求发起点反推:

  • 在 Network 中选中目标请求
  • 看 Request Payload / Query String / Headers 中的关键字段
  • 在 Sources 中全局搜索这些字段值的一部分
  • 或者在请求发起前,对 XMLHttpRequest.prototype.send / fetch 下断点

第二步:确认签名输入项,而不是只盯着输出

目标不是立刻知道“用了什么 hash”,而是要先知道:

  • 输入字段有哪些
  • 字段顺序如何
  • 是否排序
  • 空值如何处理
  • body 是原文、压缩后、还是 JSON 紧凑化后参与签名
  • 时间戳单位是秒还是毫秒

第三步:判断能不能重写,还是该“借壳运行”

如果签名逻辑只是字符串拼接 + 常见摘要,直接重写最稳。

如果签名逻辑依赖:

  • 浏览器 API
  • 混淆 runtime
  • wasm
  • 大量闭包上下文
  • 动态下发代码

那就优先考虑:

  • 浏览器内补环境执行
  • Node.js 执行原始签名函数
  • 本地 RPC 化供 Python 调用

一个教学级实战模型

为了让代码可运行,我们先构造一个典型模型:

客户端逻辑

  1. 采集浏览器环境,生成 fp_raw
  2. sha256(fp_raw) 得到 fp
  3. 请求初始化接口,拿到 seed
  4. 发业务请求时构造:
    • ts
    • nonce
    • body_md5
    • sign = sha256(path|ts|nonce|body_md5|fp|seed)

服务端逻辑

服务端验证:

  • fp 是否存在
  • seed 是否与 fp 对应
  • sign 是否正确
  • 时间戳偏差是否过大

实战代码(可运行)

下面我们先写一个本地模拟服务端,再写一个自动化客户端。这样你不仅能看懂,还能跑起来验证整个链路。


1)本地模拟服务端

保存为 server.py

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

app = Flask(__name__)

# 简单内存存储:fp -> seed
FP_SEED_STORE = {}

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

def md5_hex(s: str) -> str:
    return hashlib.md5(s.encode("utf-8")).hexdigest()

@app.route("/api/init", methods=["POST"])
def init():
    data = request.get_json(force=True)
    fp = data.get("fp", "")
    if not fp:
        return jsonify({"code": 4001, "msg": "missing fp"}), 400

    seed = secrets.token_hex(8)
    FP_SEED_STORE[fp] = seed
    return jsonify({
        "code": 0,
        "data": {
            "seed": seed,
            "expire_in": 300
        }
    })

@app.route("/api/data", methods=["POST"])
def data_api():
    data = request.get_json(force=True)
    path = "/api/data"

    fp = request.headers.get("X-Fp", "")
    ts = request.headers.get("X-Ts", "")
    nonce = request.headers.get("X-Nonce", "")
    sign = request.headers.get("X-Sign", "")

    if not all([fp, ts, nonce, sign]):
        return jsonify({"code": 4002, "msg": "missing auth headers"}), 400

    if fp not in FP_SEED_STORE:
        return jsonify({"code": 4003, "msg": "unknown fp"}), 403

    seed = FP_SEED_STORE[fp]

    # 时间窗口校验:10秒
    now = int(time.time())
    try:
        ts_int = int(ts)
    except ValueError:
        return jsonify({"code": 4004, "msg": "bad ts"}), 400

    if abs(now - ts_int) > 10:
        return jsonify({"code": 4005, "msg": "ts expired"}), 403

    body_str = request.get_data(as_text=True)
    body_md5 = md5_hex(body_str)

    plain = f"{path}|{ts}|{nonce}|{body_md5}|{fp}|{seed}"
    expected_sign = sha256_hex(plain)

    if sign != expected_sign:
        return jsonify({
            "code": 4006,
            "msg": "bad sign",
            "debug": {
                "expected": expected_sign,
                "plain": plain
            }
        }), 403

    return jsonify({
        "code": 0,
        "data": {
            "items": ["apple", "banana", "orange"],
            "echo": data
        }
    })

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

运行:

python server.py

2)自动化客户端:模拟浏览器指纹 + 初始化 + 签名请求

保存为 client.py

import hashlib
import json
import time
import uuid
import requests

BASE_URL = "http://127.0.0.1:5000"

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

def md5_hex(s: str) -> str:
    return hashlib.md5(s.encode("utf-8")).hexdigest()

def build_browser_fingerprint():
    # 教学示例:模拟从浏览器采集的一组特征
    env = {
        "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
        "lang": "zh-CN",
        "platform": "Win32",
        "screen": "1920x1080",
        "timezone": "Asia/Shanghai"
    }
    fp_raw = "|".join([f"{k}={env[k]}" for k in sorted(env.keys())])
    fp = sha256_hex(fp_raw)
    return fp_raw, fp

def init_session(fp: str):
    resp = requests.post(
        f"{BASE_URL}/api/init",
        json={"fp": fp},
        timeout=5
    )
    resp.raise_for_status()
    return resp.json()["data"]["seed"]

def build_signed_headers(path: str, body_obj: dict, fp: str, seed: str):
    ts = str(int(time.time()))
    nonce = uuid.uuid4().hex[:16]

    # 注意:签名必须和实际发送的 JSON 序列化结果一致
    body_str = json.dumps(body_obj, ensure_ascii=False, separators=(",", ":"))
    body_md5 = md5_hex(body_str)

    plain = f"{path}|{ts}|{nonce}|{body_md5}|{fp}|{seed}"
    sign = sha256_hex(plain)

    headers = {
        "Content-Type": "application/json",
        "X-Fp": fp,
        "X-Ts": ts,
        "X-Nonce": nonce,
        "X-Sign": sign
    }
    return headers, body_str, plain

def request_data():
    fp_raw, fp = build_browser_fingerprint()
    print("[*] fp_raw =", fp_raw)
    print("[*] fp     =", fp)

    seed = init_session(fp)
    print("[*] seed   =", seed)

    path = "/api/data"
    body_obj = {
        "page": 1,
        "pageSize": 20,
        "keyword": "fruit"
    }

    headers, body_str, plain = build_signed_headers(path, body_obj, fp, seed)
    print("[*] sign plain =", plain)

    resp = requests.post(
        f"{BASE_URL}{path}",
        data=body_str.encode("utf-8"),
        headers=headers,
        timeout=5
    )

    print("[*] status =", resp.status_code)
    print("[*] resp   =", resp.text)

if __name__ == "__main__":
    request_data()

运行:

python client.py

如果一切正常,会返回成功数据。


逐步验证清单

我强烈建议你在真实逆向项目里也采用这种“逐步验证”的思路,而不是一次性全量写完脚本。

清单 1:先验证指纹是否是参与项

方法:

  • 固定其他字段不变
  • 只修改 fp
  • 观察是否出现稳定拒绝

如果一改 fp 就失败,说明:

  • fp 参与签名,或者
  • fp 参与服务端额外校验

清单 2:验证 body 是否参与签名

常见误区:

  • 浏览器发的是紧凑 JSON:{"a":1,"b":2}
  • 你脚本签名时用了带空格 JSON:{"a": 1, "b": 2}

这两者字符串不同,md5(body) 当然也不同。

清单 3:验证时间戳单位

很多接口会混用:

  • 秒级时间戳:1700000000
  • 毫秒时间戳:1700000000000

如果单位错了,往往不是“签名错误”,而是直接“请求过期”。

清单 4:验证参数排序

特别是 query 参数签名时,一定要确认:

  • 是否按 key 排序
  • 是否排除空值
  • 数组怎么拼
  • 布尔值是 true/false 还是 1/0

当真实站点更复杂时,怎么落地

上面的教学模型只是基础版。现实中你大概率会遇到以下几类增强。


场景 A:签名函数在浏览器里,且依赖运行时环境

比如函数内部用到了:

  • window
  • document
  • navigator
  • performance.now()
  • Canvas

这时候不要急着纯 Python 重写。可以先把 JS 抽出来,用 Node.js 跑。

示例:sign.js

const crypto = require("crypto");

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

function md5Hex(s) {
  return crypto.createHash("md5").update(s, "utf8").digest("hex");
}

function buildSign(path, bodyStr, fp, seed, ts, nonce) {
  const bodyMd5 = md5Hex(bodyStr);
  const plain = `${path}|${ts}|${nonce}|${bodyMd5}|${fp}|${seed}`;
  const sign = sha256Hex(plain);
  return { sign, plain };
}

if (require.main === module) {
  const [path, bodyStr, fp, seed, ts, nonce] = process.argv.slice(2);
  const result = buildSign(path, bodyStr, fp, seed, ts, nonce);
  console.log(JSON.stringify(result));
}

module.exports = { buildSign };

Python 调用 Node:

import json
import subprocess

def build_sign_via_node(path, body_str, fp, seed, ts, nonce):
    result = subprocess.check_output([
        "node", "sign.js", path, body_str, fp, seed, ts, nonce
    ])
    return json.loads(result.decode("utf-8"))

这招很实用:先跑通,再优化重写。不要一开始就追求“全部纯 Python”。


场景 B:签名逻辑混淆严重,难以直读

这时候建议优先做“黑盒确认”:

  1. 给签名前的关键变量下断点
  2. 打印输入与输出
  3. 先抽样记录 5~10 次
  4. 看哪些字段变化会引起签名变化

也就是说,先搞清楚“它在算什么”,再考虑“它怎么算”。


场景 C:指纹不是本地静态生成,而是服务端参与

比如:

  • 页面先取一个 challenge
  • 再把本地环境 + challenge 混合生成 fp
  • 服务端再验算

这种模型下,自动化流程要改成:

  1. 取初始化配置
  2. 计算指纹
  3. 再签名请求

流程顺序不能乱。

stateDiagram-v2
    [*] --> LoadPage
    LoadPage --> GetChallenge
    GetChallenge --> CollectEnv
    CollectEnv --> BuildFingerprint
    BuildFingerprint --> GetSeed
    GetSeed --> SignRequest
    SignRequest --> SendRequest
    SendRequest --> Success
    SendRequest --> Rejected

常见坑与排查

这一节我尽量写得接地气一点,因为很多问题真的不是“不会算法”,而是细节踩坑。

1. JSON 序列化不一致

这是最常见的坑之一。

浏览器里很多项目实际参与签名的是:

JSON.stringify(data)

而你在 Python 里如果直接:

json.dumps(data)

默认会带空格,导致摘要不一致。

正确做法通常是:

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

2. Headers 看起来一样,实际不一样

有些服务端会额外读取:

  • Origin
  • Referer
  • User-Agent
  • Accept-Language
  • 自定义头顺序(极少见,但有)

如果你发现签名没问题但还是拒绝,记得抓浏览器真实请求做逐项比对。


3. 时间戳漂移

如果脚本部署在容器里,或者服务器时间没同步,ts 超窗会非常隐蔽。

排查方式:

  • 打印本地生成时间戳
  • 和服务端返回时间做对比
  • 必要时做 NTP 同步

4. nonce 重放

有些系统会缓存 nonce 一段时间。你本地调试时复制一次请求反复发,可能第二次就挂。

症状:

  • 第一次成功
  • 第二次同包失败
  • 提示“重复请求”或模糊的风控错误

5. 指纹字段顺序不同

如果前端是按对象 key 排序后拼接,而你是按插入顺序拼接,那么最终 fp 会不同。

教学代码里我用的是:

sorted(env.keys())

真实项目里一定要确认顺序规则。


6. 签名时用的 body 和实际发送 body 不是同一个

这也是典型坑。

错误示例:

sign_body = json.dumps(obj, separators=(",", ":"))
requests.post(url, json=obj)

requests.post(..., json=obj) 会重新序列化,不一定和你签名时完全一致。

更稳的做法是:

  • 先生成 body_str
  • 签名用它
  • 发送也用它

例如:

body_str = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
requests.post(url, data=body_str.encode("utf-8"), headers=headers)

安全/性能最佳实践

做自动化复现时,很多人只想着“能跑”,但如果你想稳定、低故障率地跑,下面这些建议很重要。

1. 把“签名输入明文”打印出来

不要只打印 sign,要打印 plain
因为调试时,真正有价值的是:

  • path 对不对
  • ts 对不对
  • nonce 对不对
  • body 摘要对不对
  • fp / seed 是否串号

这能极大缩短排查时间。


2. 指纹、token、seed 分层缓存

建议把状态分成三层:

  • 长期稳定层:浏览器环境指纹
  • 会话层:token / seed
  • 请求层:ts / nonce / sign

这样你不会每次都重新生成全部数据,也能更容易定位是哪一层失效了。


3. 对签名逻辑做单元测试

哪怕只是自己用,也建议测一下。

示例:

def test_sign():
    path = "/api/data"
    body_str = '{"page":1,"pageSize":20,"keyword":"fruit"}'
    fp = "abc123"
    seed = "seed123"
    ts = "1700000000"
    nonce = "1122334455667788"

    body_md5 = md5_hex(body_str)
    plain = f"{path}|{ts}|{nonce}|{body_md5}|{fp}|{seed}"
    sign = sha256_hex(plain)

    assert len(sign) == 64

测试的意义不只是“防写错”,更是防止你后面改代码时不小心改坏序列化方式。


4. 优先复用真实 JS,而不是盲目重写

特别是下面几种情况:

  • 混淆非常深
  • 算法更新频繁
  • 含 wasm
  • 存在复杂补环境逻辑

这时“JS 原样执行 + Python 调度”往往是维护成本最低的方案。


5. 控制请求频率,避免触发风控

自动化不是压测。
频率太高时,即使签名正确,也可能被规则命中,比如:

  • 同 IP 高频
  • 同 fp 高频
  • 同账号异常操作节奏
  • 无页面资源加载行为却连续打 API

中级阶段要建立一个意识:签名通过不等于风控通过


6. 合法合规边界

这类技术只适用于:

  • 自有系统联调
  • 授权安全测试
  • 合规研究与教学

不要把它用于未授权抓取、绕过访问控制或规避平台规则。技术本身是中性的,但使用场景必须合法。


一个更贴近真实项目的自动化组织方式

如果你准备把它做成长期维护脚本,我建议目录大致这样拆:

project/
├── client.py
├── signer/
│   ├── __init__.py
│   ├── py_sign.py
│   └── node_sign.js
├── fingerprint/
│   ├── __init__.py
│   └── builder.py
├── sessions/
│   └── store.py
└── tests/
    └── test_sign.py

职责划分:

  • fingerprint/:只负责生成环境指纹
  • signer/:只负责签名
  • sessions/:负责 seed / token 生命周期
  • client.py:串起请求流程

这样以后站点规则改了,你只需要改一层,不至于全项目一起崩。


一次完整复现的思维模型

最后把整篇内容压缩成一句实战方法论:

先建链路,再定输入,后抽算法,最后自动化。

展开就是:

  1. 先画清楚初始化、指纹、签名、请求、校验之间的关系
  2. 确认每个参数从哪里来
  3. 验证哪些字段真的参与签名
  4. 用最小可运行方案复现
  5. 再考虑重构、提速、抽象、长期维护

很多人一开始就扎进混淆 JS 里,结果三天过去,连“sign 是不是和 body 有关”都还没确认。其实这一步完全可以先靠抓包和断点确认。


总结

这次我们从一个中级实战视角,把 浏览器指纹 + 请求签名 这条链路拆成了几部分:

  • 浏览器环境采集
  • 指纹生成
  • 初始化 seed/token
  • 请求签名构造
  • 自动化复现
  • 排错与稳态维护

如果你准备真正上手,我建议按下面的顺序执行:

  1. 先抓一次完整浏览器请求链
  2. 确认初始化接口、指纹值、签名字段
  3. 手工验证 body/ts/fp 是否参与签名
  4. 先用原始 JS 或最小 Python 方案跑通
  5. 最后再做模块化与自动化

边界条件也要明确:

  • 如果算法强依赖浏览器环境,不要强行纯 Python
  • 如果服务端还有行为风控,单纯重放签名不一定成功
  • 如果规则频繁变化,优先选择可维护的“借壳执行”方案

一句话收尾:Web 逆向到了中级,拼的已经不是单点技巧,而是你能不能把“环境、时序、算法、请求”当成一个整体去拆。
只要这个视角建立起来,很多原本看起来“玄学失败”的接口,其实都能一步步落到可验证、可复现、可维护的工程化流程里。


分享到:

上一篇
《Java开发踩坑实战:排查并修复 Spring Boot 项目中的循环依赖、配置优先级与 Bean 初始化顺序问题》
下一篇
《Spring Boot 中基于 Actuator + Micrometer + Prometheus 的应用监控实战与性能告警落地》