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

《从浏览器指纹到接口签名:一次中级 Web 逆向反爬参数还原实战》

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

从浏览器指纹到接口签名:一次中级 Web 逆向反爬参数还原实战

很多人刚接触 Web 逆向时,会觉得“参数不就是抓包抄一下吗”。但真正上手后很快就会发现:请求参数里常常混着时间戳、环境指纹、随机值、摘要签名,甚至还会套一层混淆和加密。你复制一次能过,复制十次就被风控拦下来。

这篇文章我想带你走一遍比较贴近真实场景的中级实战:从浏览器指纹采集,到接口签名参数生成,再到如何在本地还原一条可运行的调用链。重点不是“背某个网站的答案”,而是学会一套稳定的分析方法

说明一下:本文只讨论授权测试、学习研究和自家系统安全评估场景,不涉及绕过他人服务的非法使用。


背景与问题

先设定一个典型场景。

某站点的列表接口,直接抓包后你会看到类似这样的请求:

POST /api/list HTTP/1.1
Content-Type: application/json

{
  "page": 1,
  "size": 20,
  "ts": 1720000000000,
  "nonce": "8f4c2e19",
  "fp": "9c3f8d2a7b...",
  "sign": "5e3b8a..."
}

如果你只把这几个字段照搬到 Python 里,多半会遇到这些现象:

  • fp 变了就过,复制旧值失效
  • sign 每次都不同,抓包重放直接 401/403
  • 浏览器里能正常返回,脚本里却提示“非法请求”
  • 同样参数,在不同 UA、不同时区、不同语言环境下结果还不一样

这说明服务端并不只是校验“有没有参数”,而是在校验:

  1. 你是不是一个像样的浏览器环境
  2. 你的参数是不是按前端逻辑动态生成
  3. 参数之间有没有一致性
  4. 时间窗口、随机数、签名摘要是否匹配

所以这类问题的核心,不是“抓到包”,而是还原参数生成机制


前置知识

如果你准备跟着做,最好具备这些基础:

  • 会用浏览器开发者工具抓包、断点、搜索源码
  • 了解 JavaScript 基本语法,知道闭包、原型、Promise
  • 知道常见摘要算法:MD5、SHA1、SHA256、HMAC
  • 会用 Python 或 Node.js 写简单脚本

环境准备

我建议准备这套工具链,足够覆盖大多数中级 Web 逆向场景:

  • Chrome / Edge DevTools
  • Fiddler / Charles / mitmproxy 任选其一
  • Node.js 18+
  • Python 3.10+
  • requestsexecjs 或直接用 subprocess 调 Node
  • 一个支持源码搜索的编辑器,比如 VS Code

安装 Python 依赖:

pip install requests

问题拆解思路

我通常会把这类反爬参数拆成三层:

  1. 环境层:浏览器指纹、UA、语言、时区、屏幕信息、Canvas/WebGL 等
  2. 算法层:参数排序、拼接、摘要、加密、混淆
  3. 协议层:时间戳有效期、nonce 去重、Header 依赖、Cookie/Session 绑定

如果你一上来就盯着 sign,很容易陷入局部。更稳的方式是先画链路。

flowchart TD
    A[页面初始化] --> B[采集浏览器环境]
    B --> C[生成指纹 fp]
    C --> D[组装业务参数]
    D --> E[拼接签名原文]
    E --> F[摘要/加密生成 sign]
    F --> G[发起接口请求]
    G --> H[服务端校验 fp ts nonce sign]

上面这个图很重要。因为你后面几乎所有排查,都是在查这条链上哪个环节还原错了。


核心原理

这一节我们把最常见的几个点讲透。

1. 浏览器指纹不一定“高级”,但一定“成体系”

很多中级站点的所谓“指纹”,并不是完整的 Canvas/WebGL 指纹方案,而是把一组环境字段拼起来做摘要,比如:

  • navigator.userAgent
  • navigator.language
  • screen.width + screen.height
  • Intl.DateTimeFormat().resolvedOptions().timeZone
  • platform
  • hardwareConcurrency

伪代码长这样:

const raw = [
  navigator.userAgent,
  navigator.language,
  screen.width + "x" + screen.height,
  Intl.DateTimeFormat().resolvedOptions().timeZone,
  navigator.platform,
  navigator.hardwareConcurrency
].join("|")

const fp = md5(raw)

这意味着:你脚本端如果只抄 fp 值,而没还原生成条件,很容易失效
因为服务端可能还会把 Header 里的 User-AgentAccept-Language 拿来做交叉比对。


2. 接口签名通常不是“单次 hash”,而是“有规则的拼接”

常见签名逻辑一般是:

  • 取业务参数
  • 加入时间戳 ts
  • 加入随机串 nonce
  • 加入指纹 fp
  • 按 key 排序
  • 拼接 secret 或固定盐
  • 再做 MD5 / SHA256 / HMAC

例如:

fp=xxx&nonce=xxx&page=1&size=20&ts=1720000000000 + secret

然后:

sign = sha256(signString)

这类逻辑看上去不复杂,但踩坑点很多:

  • 排序是字典序还是原始顺序?
  • null/undefined 要不要参与?
  • 数字和字符串是否强转?
  • 数组、对象是否 JSON 序列化?
  • 拼接时是否 URL 编码?
  • secret 是明文常量,还是运行时算出来的?

3. 混淆代码的目标不是“不可逆”,而是“拖慢定位”

实际前端里常见的不是“高强度密码学保护”,而是:

  • 函数名混淆
  • 字符串数组下标映射
  • 控制流平坦化
  • 动态 eval
  • webpack 打包后模块层层套壳

这时候不要急着“读懂全部源码”。更有效的方法是:

  1. 先定位请求发起点
  2. 找到 signfpts 在发送前的最终值
  3. 向上追调用栈
  4. 最后再还原独立算法

这个顺序非常省时间。


一次实战:从请求到参数还原

下面我构造一个接近真实项目的最小实战。代码可以直接运行,帮助你理解整个过程。

场景设定

前端发送请求时,参数生成规则如下:

  • fp = md5(ua|lang|screen|timezone|platform|cpu)
  • nonce = 8 位随机十六进制
  • ts = 当前毫秒时间戳
  • sign = sha256(sortedParams + secret)

其中 sortedParams 指将参数按 key 升序拼接为 k=v&k2=v2


第一步:还原前端签名逻辑

先用 Node.js 写出浏览器侧逻辑的“可执行版本”。

sign.js

const crypto = require('crypto');

function md5(text) {
  return crypto.createHash('md5').update(text).digest('hex');
}

function sha256(text) {
  return crypto.createHash('sha256').update(text).digest('hex');
}

function buildFingerprint(env) {
  const raw = [
    env.ua,
    env.lang,
    env.screen,
    env.timezone,
    env.platform,
    String(env.cpu)
  ].join('|');
  return md5(raw);
}

function randomNonce(len = 8) {
  return crypto.randomBytes(len / 2).toString('hex');
}

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

function buildSign(params, secret) {
  const base = sortParams(params) + secret;
  return sha256(base);
}

function buildPayload(bizParams, env) {
  const ts = Date.now();
  const nonce = randomNonce(8);
  const fp = buildFingerprint(env);

  const payload = {
    ...bizParams,
    ts,
    nonce,
    fp
  };

  payload.sign = buildSign(payload, env.secret);
  return payload;
}

if (require.main === module) {
  const env = {
    ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36',
    lang: 'zh-CN',
    screen: '1920x1080',
    timezone: 'Asia/Shanghai',
    platform: 'Win32',
    cpu: 8,
    secret: 'demo_secret_123'
  };

  const bizParams = {
    page: 1,
    size: 20
  };

  console.log(JSON.stringify(buildPayload(bizParams, env), null, 2));
}

module.exports = {
  buildFingerprint,
  buildSign,
  buildPayload
};

运行:

node sign.js

你会得到类似:

{
  "page": 1,
  "size": 20,
  "ts": 1720000000000,
  "nonce": "8f4c2e19",
  "fp": "1c0cb8ef7f4e6e2d8d2f79a66a54b2c1",
  "sign": "0f1a8d6b8d8d8e3f2c..."
}

第二步:在 Python 中调用还原逻辑

很多时候分析在浏览器里做,批量调用在 Python 里做。这是最常见组合。

client.py

import json
import subprocess
import requests

def build_payload_by_node():
    result = subprocess.run(
        ["node", "sign.js"],
        capture_output=True,
        text=True,
        check=True
    )
    return json.loads(result.stdout)

def main():
    payload = build_payload_by_node()

    url = "https://httpbin.org/post"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
        "Accept-Language": "zh-CN,zh;q=0.9",
        "Content-Type": "application/json"
    }

    resp = requests.post(url, headers=headers, json=payload, timeout=10)
    print(resp.status_code)
    print(resp.text[:500])

if __name__ == "__main__":
    main()

运行:

python client.py

虽然这里请求的是 httpbin,但结构已经完整了:Python 负责业务调度,Node 负责复刻前端参数算法

这在中级阶段是很实用的思路,因为你不一定要立刻把 JS 全部翻译成 Python。先跑通,再逐步替换。


第三步:如何在真实页面里定位 fpsign

如果你面对的是现网站点,我通常会这么做。

方法一:XHR / Fetch 断点

在 DevTools 里:

  • 打开 Sources
  • 选择 XHR/fetch Breakpoints
  • 添加接口路径关键字,比如 /api/list

请求触发时会自动断住。然后:

  • 看调用栈
  • 看局部变量
  • 搜索 signfpnonce 的最终赋值处

方法二:全局搜索关键字段

在源码里全局搜:

  • sign
  • nonce
  • timestamp
  • fingerprint
  • canvas
  • userAgent
  • sha256
  • md5

如果站点压缩严重,就搜更“行为化”的特征:

  • createHash
  • CryptoJS
  • Date.now
  • Math.random
  • sort()
  • join("&")

方法三:Hook 关键函数

有时候混淆太重,我会直接 hook 浏览器原生方法,看谁在调用。

例如 hook JSON.stringifyfetchXMLHttpRequest.prototype.send

(function() {
  const originalFetch = window.fetch;
  window.fetch = async function(url, options) {
    console.log('[fetch url]', url);
    console.log('[fetch options]', options);
    return originalFetch.apply(this, arguments);
  };
})();

如果怀疑是摘要函数,可以 hook CryptoJS.SHA256 之类的入口:

(function() {
  if (!window.CryptoJS || !window.CryptoJS.SHA256) return;
  const original = window.CryptoJS.SHA256;
  window.CryptoJS.SHA256 = function(data) {
    console.log('[SHA256 input]', data);
    const result = original.apply(this, arguments);
    console.log('[SHA256 output]', result.toString());
    return result;
  };
})();

这招我自己用得很多。尤其是代码看不下去的时候,让程序自己把答案吐出来,往往更快。


参数生成时序图

真实页面里,参数往往不是一个函数瞬间生成,而是初始化阶段、交互阶段、请求阶段逐步补齐。

sequenceDiagram
    participant U as 用户操作
    participant P as 页面脚本
    participant F as 指纹模块
    participant S as 签名模块
    participant A as 接口服务端

    U->>P: 打开页面/点击查询
    P->>F: 收集 ua/lang/screen/timezone
    F-->>P: 返回 fp
    P->>P: 生成 ts 和 nonce
    P->>S: 传入业务参数 + fp + ts + nonce
    S-->>P: 返回 sign
    P->>A: 发起请求
    A-->>P: 校验并返回结果

这个时序能帮你判断:
到底是页面初始化时就生成了 fp,还是每次请求前重新计算;sign 是不是依赖最新的 tsnonce


第四步:逐步验证,不要一次性全猜

这里给一个我自己常用的逐步验证清单。中级阶段很关键。

验证清单

  1. 先固定业务参数
    • 例如只传 page=1,size=20
  2. 确认 ts 是否有时间窗口
    • 改成旧时间戳看是否立即失效
  3. 确认 nonce 是否去重
    • 重放相同 nonce 是否被拒
  4. 确认 fp 是否与 Header 绑定
    • User-Agent 看是否影响结果
  5. 确认 sign 排序规则
    • 换 key 顺序测试签名是否变化
  6. 确认是否 URL 编码
    • 中文、空格、特殊符号最容易测出来
  7. 确认对象序列化规则
    • 嵌套参数常在这里出错

如果你按这个顺序排,基本能把问题逐个剥开,而不是在一团混乱里瞎试。


签名模块结构图

有些读者更适合从结构上理解,这里再补一张类图。

classDiagram
    class FingerprintBuilder {
      +build(env) string
      -normalizeUA(ua) string
      -normalizeScreen(screen) string
    }

    class NonceGenerator {
      +generate(len) string
    }

    class SignBuilder {
      +sortParams(params) string
      +build(params, secret) string
    }

    class ApiClient {
      +buildPayload(bizParams, env) object
      +post(url, payload) object
    }

    ApiClient --> FingerprintBuilder
    ApiClient --> NonceGenerator
    ApiClient --> SignBuilder

如果你后续准备做自动化脚本,这种拆分方式很值得直接照搬。后面排错会轻松很多。


常见坑与排查

这一部分非常重要。很多人不是不会算法,而是死在细节。

1. Header 与指纹不一致

比如你算 fp 用的是:

  • ua = Chrome 126
  • lang = zh-CN

但发请求时 Header 却是:

  • User-Agent = Python-requests/2.x
  • Accept-Language 根本没带

服务端一比对,直接判不可信。

排查建议:

  • 指纹所依赖的环境字段,尽量和请求 Header 保持一致
  • 浏览器抓包里看到什么,脚本里就尽量模拟什么

2. 时间戳单位错了

有的是毫秒 Date.now(),有的是秒 Math.floor(Date.now()/1000)
你看着只差三个零,但服务端可能就当过期处理。

排查建议:

console.log(Date.now()); // 13 位通常是毫秒

3. 排序规则错了

你以为是原顺序,实际是字典序;你以为只排业务参数,实际连 fpnoncets 一起排。

排查建议:

把签名前原文打印出来,和浏览器里最终值逐字符对比。


4. 隐藏盐值不是常量

有些站点会把 secret 再加工,例如:

  • 从 Cookie 中取一段
  • 从页面内嵌变量取 token
  • 由当天日期派生
  • 从某个初始化接口返回

这类情况下,你只抄主算法是跑不通的。

排查建议:

  • 找签名函数的入参来源
  • 看 secret 是写死,还是上游函数传入
  • 检查是否依赖 Cookie、localStorage、sessionStorage

5. JSON 序列化细节不同

对象参数最容易出问题。比如 JS 里:

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

和你 Python 里:

json.dumps({"a": 1, "b": 2})

默认空格、键顺序都可能不同。

排查建议:

Python 中尽量显式指定:

import json
text = json.dumps({"a": 1, "b": 2}, separators=(',', ':'), ensure_ascii=False)
print(text)

6. Math.random 不是唯一随机来源

不少人看到 nonce 就默认是 Math.random()。其实很多站点是:

  • crypto.getRandomValues
  • 时间戳 + 自增计数器
  • 指纹摘要截断
  • UUID 变种

排查建议:

不要猜,直接断点看 nonce 的生成函数。


一个常见排查流程示例

当接口报“签名错误”时,我建议按下面路径走:

flowchart TD
    A[接口返回签名错误] --> B{ts 是否有效}
    B -- 否 --> B1[修正秒/毫秒和时间窗口]
    B -- 是 --> C{nonce 是否重复}
    C -- 是 --> C1[改为每次重新生成]
    C -- 否 --> D{fp 与 Header 一致吗}
    D -- 否 --> D1[统一 UA 语言 时区 屏幕信息]
    D -- 是 --> E{签名原文一致吗}
    E -- 否 --> E1[检查排序 拼接 编码 序列化]
    E -- 是 --> F[检查 secret 来源和 Cookie 绑定]

这张图其实就是“别拍脑袋试”的实践版。按顺序排,比乱改参数有效得多。


安全/性能最佳实践

这一节不只是写给“逆向的人”,也写给做系统安全和接口设计的人。

1. 不要把前端签名当成真正安全边界

前端 JS 天生可见。任何放在浏览器里的 secret,最终都可能被还原。
所以:

  • 前端签名更适合做成本提升流量筛选
  • 真正的权限控制,必须在服务端完成
  • 不要指望“混淆一下”就能防住高频分析

2. 指纹要用于风控辅助,不要单点决策

浏览器指纹有价值,但有天然不稳定性:

  • 浏览器升级会变
  • 分辨率改变会变
  • 时区、语言环境会变
  • 无头环境可被伪造

更合理的方式是把它作为风控特征之一,而不是唯一准入条件。


3. 签名校验要加入时效和重放防护

服务端最好同时校验:

  • ts 时间窗口
  • nonce 一次性使用
  • 会话绑定关系
  • 参数完整性摘要

如果只校验一个静态 sign,抓包重放成本会非常低。


4. 自动化脚本中优先复用 JS,不要急着重写

如果目标是快速验证接口链路:

  • 优先直接执行原始或半还原 JS
  • 跑通后再考虑翻译到 Python
  • 对复杂混淆站点,Node 子进程方案很省时间

这是个很实用的性能与开发效率平衡点。
我自己做中级案例时,通常也是先 Node 后 Python,而不是一开始就追求“纯 Python 优雅实现”。


5. 为排查保留中间产物日志

无论你是在研究还是自测,最好都打印这些内容:

  • 指纹原文
  • 指纹值
  • 排序后的参数串
  • sign 原文
  • sign 值
  • 请求头
  • 关键 Cookie

比如:

def debug_print(title, value):
    print(f"[DEBUG] {title}: {value}")

一旦请求失败,你能快速定位到底是哪一步偏了,而不是只能看一个 403 发呆。


可运行的纯 Python 版本

如果你已经确认算法很简单,也可以直接翻译成 Python,减少 Node 依赖。

pure_python_client.py

import time
import json
import hashlib
import secrets
import requests

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

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

def build_fingerprint(env: dict) -> str:
    raw = "|".join([
        env["ua"],
        env["lang"],
        env["screen"],
        env["timezone"],
        env["platform"],
        str(env["cpu"])
    ])
    return md5(raw)

def random_nonce(length: int = 8) -> str:
    return secrets.token_hex(length // 2)

def sort_params(params: dict) -> str:
    items = []
    for key in sorted(params.keys()):
        items.append(f"{key}={params[key]}")
    return "&".join(items)

def build_sign(params: dict, secret: str) -> str:
    base = sort_params(params) + secret
    return sha256(base)

def build_payload(biz_params: dict, env: dict) -> dict:
    payload = dict(biz_params)
    payload["ts"] = int(time.time() * 1000)
    payload["nonce"] = random_nonce(8)
    payload["fp"] = build_fingerprint(env)
    payload["sign"] = build_sign(payload, env["secret"])
    return payload

def main():
    env = {
        "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36",
        "lang": "zh-CN",
        "screen": "1920x1080",
        "timezone": "Asia/Shanghai",
        "platform": "Win32",
        "cpu": 8,
        "secret": "demo_secret_123"
    }

    biz_params = {
        "page": 1,
        "size": 20
    }

    payload = build_payload(biz_params, env)
    print(json.dumps(payload, indent=2, ensure_ascii=False))

    headers = {
        "User-Agent": env["ua"],
        "Accept-Language": "zh-CN,zh;q=0.9",
        "Content-Type": "application/json"
    }

    resp = requests.post("https://httpbin.org/post", headers=headers, json=payload, timeout=10)
    print(resp.status_code)
    print(resp.text[:500])

if __name__ == "__main__":
    main()

这个版本适合在你已经把规则确认清楚之后使用。
但如果算法里混入了复杂混淆、AES、动态 token,我仍然建议你先保留 JS 版本。


边界条件:什么情况下这套方法不够用

说实话,也不是所有站点都能靠本文这套流程轻松拿下。以下情况难度会显著上升:

  • 参数生成依赖 WebAssembly
  • 指纹含 Canvas/WebGL/Audio 深度特征
  • 存在大量环境完整性检测
  • 接口签名依赖服务端下发动态密钥
  • 请求链路强绑定 Cookie、设备号、行为轨迹
  • 前端与 Native/小程序混合,算法不全在网页中

遇到这些场景时,本文的方法依然适合做第一轮拆解,但你要准备进入更深的环境模拟、Hook、协议追踪阶段。


总结

这次实战最想让你带走的,不是某个固定算法,而是一套中级 Web 逆向反爬参数还原的工作流

  1. 先把问题分成环境层、算法层、协议层
  2. 优先定位请求发起点和最终参数值
  3. fptsnoncesign 的生成链一路回溯
  4. 用最小可运行脚本验证,不要一口气重写全部逻辑
  5. 排查时逐项对比:Header、时间戳、排序、编码、secret 来源
  6. 跑通后再考虑纯 Python 化和批量调用

如果你现在正卡在某个接口的签名参数上,我给你的可执行建议是:

  • 先断点拿到签名前原文
  • 再确认 fp 是否依赖 Header 环境
  • 最后才去翻译算法

这三个动作做对了,成功率会高很多。

说得直白一点,Web 逆向到了中级阶段,拼的已经不是“会不会抓包”,而是你能不能把一个混乱的前端调用链,拆成几个可验证的小问题。只要你学会这样做,浏览器指纹、接口签名、反爬参数还原,本质上就是同一类问题。


分享到:

上一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-255》
下一篇
《Java 中基于 CompletableFuture 的异步编排实战:并行调用、超时控制与异常兜底》