浏览器指纹采集:五类核心指纹详解
浏览器指纹采集,说白了就是通过浏览器暴露的各种API,把用户的设备特征「拼图」拼出来。我做了这么多年安全,最深的感受就是——没有两个浏览器是完全一样的。哪怕同一台电脑,换个浏览器版本,指纹都不一样。
这一章我们重点讲五类核心指纹:Canvas、WebGL、AudioContext、字体、时区与语言。每一类我都会结合项目经验,告诉你哪些坑必须避开。
核心观点:单点指纹不可靠,组合指纹才是王道。你想想看,如果只靠一个Canvas指纹,误判率可能高达5%。但五类指纹组合起来,误判率能降到0.1%以下。
2.1 Canvas 指纹
Canvas指纹是目前最稳定、最常用的浏览器指纹之一。原理很简单:浏览器渲染Canvas时,不同设备、不同驱动、不同字体库会导致像素级差异。我们把这些差异提取出来,就是一个唯一的「数字签名」。
我个人习惯用两步法采集:先画一段文本,再画一个几何图形。文本能暴露字体渲染差异,图形能暴露抗锯齿和子像素渲染差异。
// Canvas 指纹采集核心代码
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 第一步:绘制文本
ctx.textBaseline = 'top';
ctx.font = '14px Arial';
ctx.fillStyle = '#f60';
ctx.fillText('Hello, 设备指纹!', 2, 2);
// 第二步:绘制几何图形
ctx.fillStyle = '#069';
ctx.beginPath();
ctx.arc(50, 50, 50, 0, Math.PI * 2, true);
ctx.fill();
// 第三步:提取像素数据并哈希
const imageData = canvas.toDataURL();
return hash(imageData); // 使用SHA-256或MD5
}
经验之谈:我曾经遇到一个坑——某些浏览器在无头模式下Canvas渲染结果完全一致。后来我加了随机偏移量,让每次绘制的位置微调1-2像素,才解决了这个问题。
2.2 WebGL 指纹
WebGL指纹比Canvas更「硬核」。它直接调用GPU驱动,暴露的是硬件层面的差异。你想想看,不同显卡的渲染管线、着色器实现、纹理压缩格式都不一样,这些差异几乎不可能伪造。
我建议采集三个维度:
- 渲染器信息:通过
gl.getParameter(gl.RENDERER)获取GPU型号 - 着色器编译结果:不同驱动对同一段着色器的编译结果可能不同
- 纹理渲染差异:绘制特定纹理后提取像素哈希
// WebGL 指纹采集示例
function getWebGLFingerprint() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return null;
// 采集GPU信息
const renderer = gl.getParameter(gl.RENDERER);
const vendor = gl.getParameter(gl.VENDOR);
// 采集着色器编译指纹
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`);
gl.compileShader(vertexShader);
// 提取编译日志(不同驱动日志格式不同)
const shaderLog = gl.getShaderInfoLog(vertexShader) || '';
return hash(renderer + vendor + shaderLog);
}
注意:WebGL指纹采集可能触发浏览器的隐私保护机制。比如Firefox的「resistFingerprinting」模式会伪造WebGL信息。我建议在采集前先检测浏览器是否启用了隐私模式,如果启用了,降低WebGL指纹的权重。
2.3 AudioContext 指纹
AudioContext指纹是个「冷门但好用」的指纹。它利用的是音频处理链中的微小差异——不同设备、不同驱动对音频信号的采样率、缓冲大小、波形生成都有细微影响。
我记得有一次帮客户做反爬虫方案,Canvas和WebGL都被模拟器绕过了,唯独AudioContext指纹暴露了对方的真实设备。为什么?因为模拟器很难完美模拟音频硬件的行为。
// AudioContext 指纹采集
function getAudioFingerprint() {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 创建振荡器
const oscillator = audioCtx.createOscillator();
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(1000, audioCtx.currentTime);
// 创建分析器
const analyser = audioCtx.createAnalyser();
oscillator.connect(analyser);
// 采集频率数据
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencyData);
// 将频率数据转为哈希
return hash(frequencyData.join(''));
}
避坑指南:我曾经遇到过AudioContext在iOS Safari上需要用户交互才能激活。解决方案是在用户点击页面后再触发采集,或者用audioCtx.resume()手动恢复上下文。
2.4 字体指纹
字体指纹的原理很简单:不同操作系统、不同浏览器预装的字体列表不一样。Windows有「微软雅黑」,macOS有「苹方」,Linux有「文泉驿」。把这些字体列表采集下来,就是一个有效的指纹。
我常用的方法是「尺寸对比法」:用一段文本,分别用已知字体和待检测字体渲染,对比渲染宽度。如果宽度一致,说明该字体存在。
// 字体检测核心逻辑
function detectFont(fontName) {
const baseFonts = ['monospace', 'sans-serif', 'serif'];
const testString = 'mmmmmmmmmmlli';
// 创建隐藏的span用于测量
const span = document.createElement('span');
span.style.position = 'absolute';
span.style.left = '-9999px';
span.style.fontSize = '72px';
span.innerHTML = testString;
document.body.appendChild(span);
// 测量基准宽度
const baseWidth = {};
baseFonts.forEach(font => {
span.style.fontFamily = font;
baseWidth[font] = span.offsetWidth;
});
// 检测目标字体
let detected = false;
baseFonts.forEach(font => {
span.style.fontFamily = `"${fontName}", ${font}`;
if (span.offsetWidth !== baseWidth[font]) {
detected = true;
}
});
document.body.removeChild(span);
return detected;
}
| 操作系统 | 预装字体数量 | 常见特有字体 |
|---|---|---|
| Windows 10/11 | 约100-150种 | 微软雅黑、Segoe UI、Consolas |
| macOS Ventura+ | 约80-120种 | 苹方、Helvetica Neue、San Francisco |
| Ubuntu 22.04+ | 约50-80种 | 文泉驿微米黑、Noto Sans CJK |
| Android 13+ | 约30-50种 | Roboto、Noto Serif CJK |
注意:字体指纹采集不要一次性检测太多字体(超过200种),否则会导致页面卡顿。我建议分批次检测,每次检测20-30种,间隔100ms以上。
2.5 时区与语言指纹
时区和语言指纹是最「轻量」的指纹,但千万别小看它。你想想看,一个用户如果时区是UTC+8,浏览器语言是zh-CN,操作系统语言是en-US——这种组合本身就很有辨识度。
我建议采集以下信息:
- 时区偏移量:通过
getTimezoneOffset()获取,注意夏令时的影响 - 浏览器语言:
navigator.language和navigator.languages - 操作系统语言:通过
navigator.language结合User-Agent推断 - 区域设置:
Intl.DateTimeFormat().resolvedOptions().locale
// 时区与语言指纹采集
function getLocaleFingerprint() {
const timezoneOffset = new Date().getTimezoneOffset();
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const language = navigator.language;
const languages = navigator.languages;
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
return hash(`${timezoneOffset}|${timezone}|${language}|${languages.join(',')}|${locale}`);
}
经验之谈:我曾经发现有些用户用VPN切换时区,但浏览器语言没改。这种「矛盾组合」反而成了识别异常行为的线索。比如一个时区在UTC-5的用户,浏览器语言却是zh-CN,大概率是爬虫或代理用户。
2.6 五类指纹的组合策略
单类指纹的稳定性有限。Canvas指纹可能因为浏览器更新而改变,WebGL指纹可能被隐私模式屏蔽。但五类指纹组合起来,稳定性就大大提升了。
我推荐以下权重分配:
| 指纹类型 | 推荐权重 | 稳定性 | 抗伪造能力 |
|---|---|---|---|
| Canvas 指纹 | 30% | 高 | 中 |
| WebGL 指纹 | 25% | 高 | 高 |
| AudioContext 指纹 | 20% | 中 | 高 |
| 字体指纹 | 15% | 中 | 低 |
| 时区与语言指纹 | 10% | 高 | 低 |
实战建议:组合指纹时不要简单拼接,建议用加权哈希。比如:finalHash = hash(canvasHash * 0.3 + webglHash * 0.25 + ...)。这样即使某一类指纹失效,整体指纹仍然可用。