从浏览器指纹到接口签名:一次中级 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、不同时区、不同语言环境下结果还不一样
这说明服务端并不只是校验“有没有参数”,而是在校验:
- 你是不是一个像样的浏览器环境
- 你的参数是不是按前端逻辑动态生成
- 参数之间有没有一致性
- 时间窗口、随机数、签名摘要是否匹配
所以这类问题的核心,不是“抓到包”,而是还原参数生成机制。
前置知识
如果你准备跟着做,最好具备这些基础:
- 会用浏览器开发者工具抓包、断点、搜索源码
- 了解 JavaScript 基本语法,知道闭包、原型、Promise
- 知道常见摘要算法:MD5、SHA1、SHA256、HMAC
- 会用 Python 或 Node.js 写简单脚本
环境准备
我建议准备这套工具链,足够覆盖大多数中级 Web 逆向场景:
- Chrome / Edge DevTools
- Fiddler / Charles / mitmproxy 任选其一
- Node.js 18+
- Python 3.10+
requests、execjs或直接用subprocess调 Node- 一个支持源码搜索的编辑器,比如 VS Code
安装 Python 依赖:
pip install requests
问题拆解思路
我通常会把这类反爬参数拆成三层:
- 环境层:浏览器指纹、UA、语言、时区、屏幕信息、Canvas/WebGL 等
- 算法层:参数排序、拼接、摘要、加密、混淆
- 协议层:时间戳有效期、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.userAgentnavigator.languagescreen.width + screen.heightIntl.DateTimeFormat().resolvedOptions().timeZoneplatformhardwareConcurrency
伪代码长这样:
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-Agent、Accept-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 打包后模块层层套壳
这时候不要急着“读懂全部源码”。更有效的方法是:
- 先定位请求发起点
- 找到
sign、fp、ts在发送前的最终值 - 向上追调用栈
- 最后再还原独立算法
这个顺序非常省时间。
一次实战:从请求到参数还原
下面我构造一个接近真实项目的最小实战。代码可以直接运行,帮助你理解整个过程。
场景设定
前端发送请求时,参数生成规则如下:
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。先跑通,再逐步替换。
第三步:如何在真实页面里定位 fp 和 sign
如果你面对的是现网站点,我通常会这么做。
方法一:XHR / Fetch 断点
在 DevTools 里:
- 打开
Sources - 选择
XHR/fetch Breakpoints - 添加接口路径关键字,比如
/api/list
请求触发时会自动断住。然后:
- 看调用栈
- 看局部变量
- 搜索
sign、fp、nonce的最终赋值处
方法二:全局搜索关键字段
在源码里全局搜:
signnoncetimestampfingerprintcanvasuserAgentsha256md5
如果站点压缩严重,就搜更“行为化”的特征:
createHashCryptoJSDate.nowMath.randomsort()join("&")
方法三:Hook 关键函数
有时候混淆太重,我会直接 hook 浏览器原生方法,看谁在调用。
例如 hook JSON.stringify、fetch、XMLHttpRequest.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 是不是依赖最新的 ts 和 nonce。
第四步:逐步验证,不要一次性全猜
这里给一个我自己常用的逐步验证清单。中级阶段很关键。
验证清单
- 先固定业务参数
- 例如只传
page=1,size=20
- 例如只传
- 确认
ts是否有时间窗口- 改成旧时间戳看是否立即失效
- 确认
nonce是否去重- 重放相同 nonce 是否被拒
- 确认
fp是否与 Header 绑定- 改
User-Agent看是否影响结果
- 改
- 确认
sign排序规则- 换 key 顺序测试签名是否变化
- 确认是否 URL 编码
- 中文、空格、特殊符号最容易测出来
- 确认对象序列化规则
- 嵌套参数常在这里出错
如果你按这个顺序排,基本能把问题逐个剥开,而不是在一团混乱里瞎试。
签名模块结构图
有些读者更适合从结构上理解,这里再补一张类图。
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 126lang = zh-CN
但发请求时 Header 却是:
User-Agent = Python-requests/2.xAccept-Language根本没带
服务端一比对,直接判不可信。
排查建议:
- 指纹所依赖的环境字段,尽量和请求 Header 保持一致
- 浏览器抓包里看到什么,脚本里就尽量模拟什么
2. 时间戳单位错了
有的是毫秒 Date.now(),有的是秒 Math.floor(Date.now()/1000)。
你看着只差三个零,但服务端可能就当过期处理。
排查建议:
console.log(Date.now()); // 13 位通常是毫秒
3. 排序规则错了
你以为是原顺序,实际是字典序;你以为只排业务参数,实际连 fp、nonce、ts 一起排。
排查建议:
把签名前原文打印出来,和浏览器里最终值逐字符对比。
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 逆向反爬参数还原的工作流:
- 先把问题分成环境层、算法层、协议层
- 优先定位请求发起点和最终参数值
- 从
fp、ts、nonce、sign的生成链一路回溯 - 用最小可运行脚本验证,不要一口气重写全部逻辑
- 排查时逐项对比:Header、时间戳、排序、编码、secret 来源
- 跑通后再考虑纯 Python 化和批量调用
如果你现在正卡在某个接口的签名参数上,我给你的可执行建议是:
- 先断点拿到签名前原文
- 再确认
fp是否依赖 Header 环境 - 最后才去翻译算法
这三个动作做对了,成功率会高很多。
说得直白一点,Web 逆向到了中级阶段,拼的已经不是“会不会抓包”,而是你能不能把一个混乱的前端调用链,拆成几个可验证的小问题。只要你学会这样做,浏览器指纹、接口签名、反爬参数还原,本质上就是同一类问题。