浏览器指纹基础:Canvas指纹、WebGL指纹、AudioContext指纹、Font指纹
说到浏览器指纹,很多人第一反应是「不就是收集点浏览器信息吗?」。其实没那么简单。我做了这么多年设备指纹,可以负责任地告诉你:浏览器指纹是设备指纹体系里最基础、也最容易被篡改的一层。
为什么?因为浏览器暴露给 JavaScript 的 API 太多了。每个 API 都能提取出一些「看起来一样、实际上有细微差异」的数据。把这些数据组合起来,就能形成一串几乎唯一的标识。
这一章,我们重点拆解四个最核心的指纹维度:Canvas、WebGL、AudioContext 和 Font。它们各有各的脾气,也各有各的坑。
Canvas 指纹:最经典的「图形指纹」
Canvas 指纹的原理其实很简单。你让浏览器画一张图——画点文字、画些几何形状、加点颜色渐变。然后你把画布上的像素数据读出来,算一个哈希值。
关键来了:不同设备、不同浏览器、甚至不同显卡驱动,渲染同一段 Canvas 指令,结果会有细微差异。抗锯齿算法的实现不同、子像素渲染的偏移不同、字体渲染引擎的版本不同……这些差异都会体现在像素数据里。
我举个例子。你在 Chrome 和 Firefox 上分别画一段「Hello, World!」文字,然后读取像素。两个结果大概率不一样。这就是 Canvas 指纹的威力。
// 一个典型的 Canvas 指纹采集代码
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 50;
const ctx = canvas.getContext('2d');
// 绘制背景
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, 200, 50);
// 绘制文字
ctx.font = '18px Arial';
ctx.fillStyle = '#333';
ctx.fillText('Hello, World!', 10, 30);
// 绘制几何图形
ctx.beginPath();
ctx.arc(150, 25, 15, 0, Math.PI * 2);
ctx.fillStyle = '#3498db';
ctx.fill();
// 读取像素数据
const imageData = ctx.getImageData(0, 0, 200, 50);
const data = imageData.data;
// 计算哈希(简化示例)
let hash = 0;
for (let i = 0; i < data.length; i += 4) {
hash = ((hash << 5) - hash) + data[i];
hash |= 0;
}
return hash.toString(16);
}
WebGL 指纹:深入 GPU 层面
WebGL 指纹比 Canvas 更「底层」一些。它直接跟 GPU 打交道。你通过 WebGL API 可以获取到:
- GPU 型号和厂商(比如 NVIDIA GeForce RTX 3080)
- 渲染器信息(WebGL 渲染器的具体实现)
- 支持的扩展列表(不同 GPU 支持的 OpenGL 扩展不同)
- 着色器编译结果(同一段着色器代码在不同 GPU 上可能产生不同的二进制)
你想想看,这些信息组合起来,几乎能唯一确定一块显卡。而且 GPU 信息很难被软件层面篡改——除非你换了显卡或者重装了驱动。
我记得有一次做反爬项目,对方用了一台云服务器。Canvas 指纹完全一样,但 WebGL 指纹暴露了 GPU 型号是「VMware SVGA 3D」。嗯,虚拟机的特征一下就出来了。
// 获取 WebGL 基本信息
function getWebGLFingerprint() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return null;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
return {
vendor: gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL),
renderer: gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL),
version: gl.getParameter(gl.VERSION),
shadingLanguageVersion: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
extensions: gl.getSupportedExtensions()
};
}
AudioContext 指纹:声音里的「硬件签名」
这个维度可能有点反直觉——声音跟设备指纹有什么关系?
其实原理是这样的:浏览器播放一段音频信号,经过音频处理链路(数模转换、放大器、扬声器……),再录回来。不同设备的音频硬件和驱动,会对信号产生不同的「失真」。这个失真模式就是指纹。
当然,我们不会真的去播放和录音。那样太慢了,而且用户能听到声音。实际做法是:利用 AudioContext 的振荡器生成一段信号,然后通过 AnalyserNode 分析频域数据。整个过程在内存中完成,用户完全无感知。
我刚开始研究这个的时候也觉得有点玄学。直到我在两台同型号的 MacBook Pro 上测试——它们居然产生了不同的 AudioContext 指纹。硬件批次不同,音频芯片的微小差异被捕捉到了。
// AudioContext 指纹采集(简化版)
function getAudioFingerprint() {
return new Promise((resolve) => {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
const analyser = audioCtx.createAnalyser();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(analyser);
analyser.connect(audioCtx.destination);
oscillator.frequency.value = 440;
oscillator.type = 'sine';
gainNode.gain.value = 0.001; // 极低音量,用户听不到
oscillator.start(0);
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
setTimeout(() => {
analyser.getByteFrequencyData(dataArray);
let hash = 0;
for (let i = 0; i < bufferLength; i++) {
hash = ((hash << 5) - hash) + dataArray[i];
hash |= 0;
}
oscillator.stop();
audioCtx.close();
resolve(hash.toString(16));
}, 100);
});
}
Font 指纹:系统字体列表的「身份牌」
Font 指纹的思路更直接——不同操作系统、不同版本、不同语言包,预装的字体列表都不一样。你只需要检测浏览器能渲染哪些字体,就能得到一串特征码。
具体做法是:用 JavaScript 创建一个元素,设置不同的 font-family,然后测量它的宽度。如果某个字体存在,元素的宽度会变化;如果不存在,浏览器会 fallback 到默认字体,宽度不变。
你想想看,Windows 10 中文版和 Windows 10 英文版,预装字体就差很多。macOS 和 Linux 更是天差地别。Font 指纹在区分操作系统和语言环境时特别有效。
| 操作系统 | 常见预装字体 | 指纹特征 |
|---|---|---|
| Windows 10 中文版 | 微软雅黑、宋体、SimHei | 中文字体丰富,英文字体较少 |
| macOS Ventura | 苹方、Helvetica、San Francisco | 苹果特有字体多,无微软字体 |
| Ubuntu 22.04 | Noto Sans、Ubuntu、DejaVu | 开源字体为主,无商业字体 |
| Android 13 | Roboto、Noto Serif | 字体列表较短,以 Google 字体为主 |
// 检测单个字体是否存在
function isFontAvailable(fontName) {
const baseFonts = ['monospace', 'sans-serif', 'serif'];
const testString = 'mmmmmmmmmmlli';
const testSize = '72px';
const container = document.createElement('div');
container.style.position = 'absolute';
container.style.left = '-9999px';
container.style.visibility = 'hidden';
document.body.appendChild(container);
const baseWidths = {};
for (const base of baseFonts) {
const span = document.createElement('span');
span.style.font = `${testSize} ${base}`;
span.textContent = testString;
container.appendChild(span);
baseWidths[base] = span.offsetWidth;
container.removeChild(span);
}
const testSpan = document.createElement('span');
testSpan.style.font = `${testSize} ${fontName}, monospace`;
testSpan.textContent = testString;
container.appendChild(testSpan);
const testWidth = testSpan.offsetWidth;
document.body.removeChild(container);
return testWidth !== baseWidths['monospace'];
}
四个维度的组合策略
单独看每个维度,都有各自的弱点。但把它们组合起来,效果就完全不一样了。
我个人习惯的做法是:
- 优先采集 Canvas 和 WebGL——这两个维度稳定性最高,区分度最好
- AudioContext 作为补充——在 Canvas/WebGL 被屏蔽时,AudioContext 能提供额外的区分度
- Font 作为辅助验证——用于确认操作系统和语言环境,但不作为主标识
- 给每个维度分配权重——Canvas 和 WebGL 各占 35%,AudioContext 占 20%,Font 占 10%
这样组合下来,即使某个维度被篡改或屏蔽,其他维度仍然能维持指纹的稳定性。说白了,这就是「不要把鸡蛋放在同一个篮子里」的工程实践。