浏览器指纹逆向,通常不是拿到一串哈希值后"解密出设备"。更可验证的任务是还原一条链路:
浏览器 API 返回原始值 → 页面脚本筛选和组合 → 生成待提交的数据 → 发出请求
这条链路中的每一步都需要单独找证据。脚本读取了 Canvas,并不等于 Canvas 结果进入了最终请求;请求带有一个 fp 字段,也不等于我们知道服务端如何使用它。浏览器指纹本身可以由浏览器及系统暴露的多种特征组成,例如语言、时区、屏幕信息和渲染结果。MDN 的定义可作为概念起点。
本文以自有页面或获授权的测试页面为分析对象,演示如何定位和记录客户端采集链。
一、先找输出:哪一次请求可能带走了环境信息?
打开 Chrome DevTools 的 Network 面板,再重新加载页面。建议从页面加载前开始记录,并先查看全部请求,不要过早只筛选 Fetch/XHR:提交方式和触发时机未确认前,筛选条件可能让你漏掉线索。
对候选请求依次检查:
- 触发时机:打开页面就发送,还是点击、登录或离开页面时发送?
- Payload:有无语言、时区、屏幕、图形信息,或经过处理的标识?
- Initiator:请求由哪个脚本或调用位置发起?
- 重复性:刷新页面后,哪些字段保持不变,哪些字段变化?
DevTools 的 Network 面板提供请求详情及发起位置等信息,具体入口可参阅 Chrome 官方文档。
请求名中的 device、risk、fingerprint 只能帮助缩小范围,不能单独证明它是指纹上报。反过来,请求名完全没有这些词,也不能排除它携带相关数据。
二、再找输入:脚本读取了哪些浏览器值?

通过候选请求的 Initiator 回到代码,在 Sources 中查看数据对象的生成位置。如果代码被压缩,可以先格式化,再围绕赋值和函数调用逐步追踪;必要时对相关代码或 XHR/fetch 调用设置断点。Chrome DevTools 断点文档列出了可用的断点方式。
观察时,把证据分成三类:
| 层次 | 要记录的内容 | 需要避免的推断 |
|---|---|---|
| 原始输入 | API 名称、调用位置、实际返回值 | "调用过"就等于"最终使用了" |
| 中间处理 | 字段筛选、排序、序列化、编码或摘要计算 | "生成了哈希"就等于"能反推出输入" |
| 请求输出 | 请求体中的字段和值、发送时机 | "已上报"就等于"知道服务端判定规则" |
例如,看到 canvas.toDataURL(),可以确认代码在该位置导出了画布内容;还需要继续追踪返回值去了哪里。MDN 的 toDataURL() 文档说明了该方法的返回形式。
三、用一个本地示例理解"原始值"和"摘要值"
下面的代码只用于自己的 HTTPS 页面或本地测试页面 。它读取少量浏览器信息,计算 SHA-256 摘要并打印结果,不会发送网络请求:
async function inspectFingerprintDemo() {
const canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 50;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("当前环境无法创建 Canvas 2D 上下文");
}
ctx.font = "20px sans-serif";
ctx.fillText("fingerprint demo", 8, 30);
const inputs = {
language: navigator.language,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
canvas: canvas.toDataURL()
};
const serialized = JSON.stringify(inputs);
const bytes = new TextEncoder().encode(serialized);
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
const digest = Array.from(new Uint8Array(hashBuffer))
.map(byte => byte.toString(16).padStart(2, "0"))
.join("");
console.log({
language: inputs.language,
timeZone: inputs.timeZone,
canvasLength: inputs.canvas.length,
serializedLength: serialized.length,
digest
});
}
inspectFingerprintDemo();
这个示例刻意把两个状态分开:inputs 是可观察的原始值,digest 是根据序列化结果计算的摘要。SubtleCrypto.digest() 用于计算摘要,且需要安全上下文;详情见 MDN 文档。
真实页面可能使用不同的字段、顺序和处理方式。即使两处代码都写着 SHA-256,只要输入字段或序列化结果不同,输出也无法直接对照。因此,逆向分析时要记录参与计算的确切输入,而不只是算法名称。
四、请求找不到时,排查观察条件
如果 Network 面板没有出现预期请求,先检查记录是否覆盖了页面初始化全过程,以及是否被筛选条件隐藏。然后再观察交互或页面离开时机。某些页面可能使用 navigator.sendBeacon() 异步发送少量数据,因此仅盯着一个函数名或一种触发时机并不充分。MDN:sendBeacon()
如果找到了请求,却无法把请求字段对应到某个 API 返回值,不要急着为字段"猜含义"。可以在运行时逐步比较:
- API 返回后,对象中出现了什么值?
- 进入处理函数前后,值发生了什么变化?
- 请求体保存的是原值、部分字段,还是处理后的结果?
找到一个相同字符串只能算线索。只有调用关系、运行时值和请求输出能够连起来,结论才较为可靠。
五、报告里怎样写,才不超出证据?
一个可复查的结论可以写成:
在本次测试条件下,脚本读取了语言、时区和 Canvas 导出结果;观察到其中若干值经过序列化及摘要计算;随后某次请求携带了处理后的字段。
若没有服务端代码或其他直接证据,就不应把它写成"网站一定依据这个哈希识别了某个人"。IP、Cookie、网络连接特征和服务端历史记录也可能参与系统判断,但它们不等同于这段 JavaScript 的采集结果。若要进一步区分浏览器可读取的信号、浏览器身份与长期保存的环境状态,可以参考 Web4 关于浏览器指纹与环境一致性的说明;具体页面实际使用了什么,仍须以该页面的代码和请求证据为准。
浏览器指纹逆向的终点不是猜出一个万能算法,而是交付一条可复核的证据链:输入是什么、在哪一步变化、最终输出了什么,以及哪些用途仍未得到证实。