从浏览器指纹到请求签名:一次 Web 逆向中级实战拆解与自动化复现
很多人学 Web 逆向时,卡住的不是“怎么抓包”,而是抓到包之后发现:参数看起来都在,服务端却还是判你无效。这类问题往往不只是一个 sign 字段没算对,而是背后还有一整条链路:
- 浏览器环境采集
- 指纹生成
- 动态 token 下发
- 请求签名
- 风控校验
- 时序一致性检查
这篇文章我想用一个中级实战视角,带你把这条链路拆开,然后做一个自动化复现。重点不是“某个站点的私有算法”,而是掌握一套可迁移的方法:面对浏览器指纹 + 请求签名的组合型防护,如何系统分析、逐步验证、最后稳定复现。
背景与问题
在实际项目里,我们常见这样的接口防护:
- 首次打开页面时,前端 JavaScript 收集浏览器环境信息
- 这些信息被整理成指纹串,可能会做哈希或加密
- 页面再根据时间戳、路径、body、token、指纹等数据计算请求签名
- 服务端验证:
- 签名是否正确
- 指纹是否合法
- 请求顺序是否合理
- 时间戳是否超窗
- 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. 浏览器指纹是什么
浏览器指纹不是单一字段,而是一组环境特征的组合,比如:
userAgentlanguageplatformscreen分辨率- 时区
- 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: 返回数据或拒绝
实战里我通常这样做
第一步:先找“谁最早生成了关键参数”
关注这些关键词:
signtokenfingerprintdeviceIdnoncetimestampencrypthashAuthorization
如果是打包代码,不一定能直接搜到明文名字,那就从请求发起点反推:
- 在 Network 中选中目标请求
- 看 Request Payload / Query String / Headers 中的关键字段
- 在 Sources 中全局搜索这些字段值的一部分
- 或者在请求发起前,对
XMLHttpRequest.prototype.send/fetch下断点
第二步:确认签名输入项,而不是只盯着输出
目标不是立刻知道“用了什么 hash”,而是要先知道:
- 输入字段有哪些
- 字段顺序如何
- 是否排序
- 空值如何处理
- body 是原文、压缩后、还是 JSON 紧凑化后参与签名
- 时间戳单位是秒还是毫秒
第三步:判断能不能重写,还是该“借壳运行”
如果签名逻辑只是字符串拼接 + 常见摘要,直接重写最稳。
如果签名逻辑依赖:
- 浏览器 API
- 混淆 runtime
- wasm
- 大量闭包上下文
- 动态下发代码
那就优先考虑:
- 浏览器内补环境执行
- Node.js 执行原始签名函数
- 本地 RPC 化供 Python 调用
一个教学级实战模型
为了让代码可运行,我们先构造一个典型模型:
客户端逻辑
- 采集浏览器环境,生成
fp_raw - 用
sha256(fp_raw)得到fp - 请求初始化接口,拿到
seed - 发业务请求时构造:
tsnoncebody_md5sign = 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:签名函数在浏览器里,且依赖运行时环境
比如函数内部用到了:
windowdocumentnavigatorperformance.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:签名逻辑混淆严重,难以直读
这时候建议优先做“黑盒确认”:
- 给签名前的关键变量下断点
- 打印输入与输出
- 先抽样记录 5~10 次
- 看哪些字段变化会引起签名变化
也就是说,先搞清楚“它在算什么”,再考虑“它怎么算”。
场景 C:指纹不是本地静态生成,而是服务端参与
比如:
- 页面先取一个
challenge - 再把本地环境 +
challenge混合生成fp - 服务端再验算
这种模型下,自动化流程要改成:
- 取初始化配置
- 计算指纹
- 再签名请求
流程顺序不能乱。
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 看起来一样,实际不一样
有些服务端会额外读取:
OriginRefererUser-AgentAccept-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:串起请求流程
这样以后站点规则改了,你只需要改一层,不至于全项目一起崩。
一次完整复现的思维模型
最后把整篇内容压缩成一句实战方法论:
先建链路,再定输入,后抽算法,最后自动化。
展开就是:
- 先画清楚初始化、指纹、签名、请求、校验之间的关系
- 确认每个参数从哪里来
- 验证哪些字段真的参与签名
- 用最小可运行方案复现
- 再考虑重构、提速、抽象、长期维护
很多人一开始就扎进混淆 JS 里,结果三天过去,连“sign 是不是和 body 有关”都还没确认。其实这一步完全可以先靠抓包和断点确认。
总结
这次我们从一个中级实战视角,把 浏览器指纹 + 请求签名 这条链路拆成了几部分:
- 浏览器环境采集
- 指纹生成
- 初始化 seed/token
- 请求签名构造
- 自动化复现
- 排错与稳态维护
如果你准备真正上手,我建议按下面的顺序执行:
- 先抓一次完整浏览器请求链
- 确认初始化接口、指纹值、签名字段
- 手工验证 body/ts/fp 是否参与签名
- 先用原始 JS 或最小 Python 方案跑通
- 最后再做模块化与自动化
边界条件也要明确:
- 如果算法强依赖浏览器环境,不要强行纯 Python
- 如果服务端还有行为风控,单纯重放签名不一定成功
- 如果规则频繁变化,优先选择可维护的“借壳执行”方案
一句话收尾:Web 逆向到了中级,拼的已经不是单点技巧,而是你能不能把“环境、时序、算法、请求”当成一个整体去拆。
只要这个视角建立起来,很多原本看起来“玄学失败”的接口,其实都能一步步落到可验证、可复现、可维护的工程化流程里。