前言
在上一篇文章《浏览器指纹入门:从Cookie到设备识别》中,我们介绍了浏览器指纹的基本概念和简单实现。本文将进一步深入,探讨Canvas指纹、WebGL指纹、AudioContext指纹等高阶采集技术,并构建一个完整的、生产可用的多维度指纹采集系统。
如果你对浏览器指纹完全陌生,建议先阅读入门篇了解基础背景。本文假设你已熟悉 JavaScript、Canvas API 和基本的哈希算法。
一、Canvas指纹原理深入
1.1 为什么不同设备的渲染结果不同?
Canvas指纹的核心原理是:同一段 Canvas 绘制代码,在不同设备、不同浏览器、不同显卡驱动下,会产生像素级别差异的渲染结果。这些差异来源于:
| 因素 | 影响 |
|------|------|
| 字体渲染引擎 | Windows 的 ClearType、macOS 的 Core Text、Linux 的 FreeType 亚像素渲染方式不同 |
| 抗锯齿算法 | 各平台对文本和图形的边缘平滑处理方法不同 |
| 子像素渲染 | LCD 屏幕的子像素排列方式导致颜色偏差 |
| GPU/显卡驱动 | 不同 GPU 对渐变、阴影的硬件加速处理存在微小差异 |
| 颜色管理 | 各浏览器的色彩空间和伽马校正策略不同 |
1.2 一个经典案例
下面是业界著名的 "FingerprintJS" 风格的 Canvas 测试图。它在同一画布上绘制文本、形状、渐变和色彩,充分暴露底层差异:
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 200;
const ctx = canvas.getContext('2d');
// 1. 绘制背景渐变——不同 GPU 对渐变的插值算法有差异
const gradient = ctx.createLinearGradient(0, 0, 400, 200);
gradient.addColorStop(0, '#ff0000');
gradient.addColorStop(0.5, '#00ff00');
gradient.addColorStop(1, '#0000ff');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 200);
// 2. 绘制文本——字体渲染差异最大
ctx.font = 'bold 28px "Arial"';
ctx.fillStyle = '#ffffff';
ctx.fillText('Browser Fingerprint', 30, 50);
ctx.font = '18px "Times New Roman"';
ctx.fillStyle = '#ffff00';
ctx.fillText('Canvas: 4A7F...', 30, 100);
// 3. 绘制形状——抗锯齿差异
ctx.beginPath();
ctx.arc(320, 140, 40, 0, Math.PI * 2);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 4;
ctx.stroke();
ctx.beginPath();
ctx.arc(320, 140, 30, 0, Math.PI * 1.5);
ctx.fillStyle = 'rgba(255,255,0,0.5)';
ctx.fill();
// 4. 绘制线条和贝塞尔曲线
ctx.beginPath();
ctx.moveTo(30, 160);
ctx.lineTo(150, 180);
ctx.quadraticCurveTo(200, 120, 280, 170);
ctx.strokeStyle = '#00ffff';
ctx.lineWidth = 3;
ctx.stroke();
// 转为 DataURL 并计算哈希
const dataURL = canvas.toDataURL('image/png');
return simpleHash(dataURL);
}
💡 **关键点**:`toDataURL('image/png')` 导出的 PNG 数据是**无损的**,确保每次相同设备渲染出完全一致的像素时,哈希值也完全一致。
二、构建哈希函数
在上面的代码中我们使用了 simpleHash 函数。在实际生产环境中,推荐使用 SHA-256 以获得更好的分布性:
async function sha256Hash(data) {
const encoder = new TextEncoder();
const dataBuffer = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
如果浏览器不支持 SubtleCrypto(极少数旧浏览器),可以用一个简单的 DJB2 哈希作为 fallback:
function simpleHash(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) + str.charCodeAt(i);
hash = hash & hash; // 转换为32位整数
}
return '00000' + (hash >>> 0).toString(16);
}
三、WebGL指纹分析
WebGL(Web Graphics Library)允许浏览器直接调用底层 GPU 进行 3D 渲染。正因为如此,它能暴露比 Canvas 更多的设备级信息。
3.1 获取渲染器与厂商信息
function getWebGLInfo() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
return { supported: false };
}
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
return {
supported: true,
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),
vendorUnmasked: gl.getParameter(gl.VENDOR),
rendererUnmasked: gl.getParameter(gl.RENDERER),
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
maxVertexAttribs: gl.getParameter(gl.MAX_VERTEX_ATTRIBS),
extensions: gl.getSupportedExtensions(),
};
}
这段代码能返回类似下面的结果,足以唯一标识设备:
{
"vendor": "Google Inc. (Intel)",
"renderer": "ANGLE (Intel, Intel(R) UHD Graphics 620 (0x00005917) Direct3D11 vs_5_0 ps_5_0)",
"version": "WebGL 1.0 (OpenGL ES 2.0 Chromium)",
"maxTextureSize": 16384
}
3.2 WebGL 渲染指纹——3D 图形差异
光靠字符串信息还不够,我们通过实际渲染 3D 场景来获取像素级指纹。不同 GPU 对着色器编译、浮点精度、纹理过滤的处理各有差异:
function getWebGLRenderFingerprint() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const gl = canvas.getContext('webgl');
if (!gl) return null;
// 顶点着色器:简单的 2D 三角形
const vsSource = `
attribute vec4 aVertexPos;
void main(void) {
gl_Position = aVertexPos;
}
`;
// 片段着色器:包含特定运算,不同 GPU 浮点精度不同
const fsSource = `
precision highp float;
void main(void) {
float r = sin(3.14159 * 0.3);
float g = cos(2.71828 * 0.7);
float b = tan(1.41421 * 0.5);
float a = mod(3.14159, 1.61803);
gl_FragColor = vec4(r, g, b, a);
}
`;
function compileShader(source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
}
const vertexShader = compileShader(vsSource, gl.VERTEX_SHADER);
const fragmentShader = compileShader(fsSource, gl.FRAGMENT_SHADER);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// 顶点数据:一个覆盖全屏的三角形
const vertices = new Float32Array([
-1.0, -1.0,
1.0, -1.0,
-1.0, 1.0,
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const posAttr = gl.getAttribLocation(program, 'aVertexPos');
gl.vertexAttribPointer(posAttr, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(posAttr);
gl.viewport(0, 0, 256, 256);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const pixels = new Uint8Array(256 * 256 * 4);
gl.readPixels(0, 0, 256, 256, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return sha256Hash(Array.from(pixels).join(','));
}
⚠️ **注意**:`readPixels` 在某些设备上可能触发安全警告或被 WebGL 安全策略限制。建议加上 try-catch 并准备 fallback。
3.3 WebGL 扩展列表指纹
不同设备支持的 WebGL 扩展也各不相同。扩展列表本身是一个不错的指纹维度:
// 将扩展列表排序后哈希,避免顺序随机性
const extensions = gl.getSupportedExtensions().sort();
const extFingerprint = await sha256Hash(extensions.join(','));
四、AudioContext 指纹
音频指纹利用的是音频处理管道的微小差异。不同设备、驱动、浏览器对音频信号的处理会产生不同的浮点结果。
function getAudioFingerprint() {
return new Promise((resolve) => {
try {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const analyser = audioCtx.createAnalyser();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.type = 'triangle';
oscillator.frequency.setValueAtTime(10000, audioCtx.currentTime);
gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime);
oscillator.connect(gainNode);
gainNode.connect(analyser);
analyser.connect(audioCtx.destination);
oscillator.start(0);
const samples = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(samples);
oscillator.stop(0);
audioCtx.close();
// 将采样数据哈希
const sampleStr = Array.from(samples.slice(0, 100)).join(',');
resolve(sha256Hash(sampleStr));
} catch (e) {
resolve(null); // 不支持时返回 null
}
});
}
关键观察:AudioContext 指纹在**隐私模式(无痕模式)** 下可能被禁用或返回空数据,需要将此维度的权重适当降低。
五、客户端矩形(ClientRects)与 CSS 特性检测
5.1 ClientRects 指纹
getClientRects() 返回元素在视口中的位置和尺寸信息。同样一段 HTML+CSS,不同浏览器对文本的换行、字母间距、字形尺寸的处理不同,导致返回的矩形集合有细微差异。
function getClientRectsFingerprint() {
const container = document.createElement('div');
container.style.cssText = 'position:absolute;left:-9999px;top:-9999px;';
container.innerHTML = `
The quick brown fox jumps over the lazy dog. 1234567890
浏览器指纹 ClientRects 测试
`;
document.body.appendChild(container);
const spans = container.querySelectorAll('span');
const rects = [];
spans.forEach(span => {
const clientRects = span.getClientRects();
for (let i = 0; i < clientRects.length; i++) {
const rect = clientRects[i];
rects.push(`${rect.top},${rect.left},${rect.width},${rect.height}`);
}
});
document.body.removeChild(container);
return sha256Hash(rects.join('|'));
}
5.2 CSS 特性检测
结合 CSS 特性检测,可以判断浏览器对特定 CSS 属性的支持情况,这也构成一个有用的指纹维度:
function getCSSFeatureFingerprint() {
const features = [];
const tests = {
'backdrop-filter': CSS.supports('backdrop-filter', 'blur(10px)'),
'container-queries': CSS.supports('container-type', 'inline-size'),
'has-selector': CSS.supports('selector(:has(div))'),
'subgrid': CSS.supports('grid-template-columns', 'subgrid'),
'scroll-timeline': CSS.supports('scroll-timeline-name', '--test'),
'layer': CSS.supports('@layer', ''),
'color-gamut-p3': window.matchMedia('(color-gamut: p3)').matches,
'prefers-reduced-motion': window.matchMedia('(prefers-reduced-motion: reduce)').matches,
'prefers-color-scheme': window.matchMedia('(prefers-color-scheme: dark)').matches,
};
for (const [key, value] of Object.entries(tests)) {
features.push(`${key}:${value}`);
}
return sha256Hash(features.join(','));
}
六、多维度指纹融合策略
单个维度的指纹可能会因为浏览器更新、插件变化、系统升级而改变。多维度融合的核心思路是:
6.1 加权评分法
const WEIGHTS = {
canvas: 0.25,
webgl_renderer: 0.20,
webgl_render: 0.15,
audio: 0.10,
client_rects: 0.10,
css_features: 0.05,
fonts: 0.10, // 系统字体列表(篇幅限制,本文未展开)
timezone: 0.05,
};
function computeSimilarity(storedFingerprint, currentFingerprint) {
let score = 0;
for (const [dim, weight] of Object.entries(WEIGHTS)) {
if (storedFingerprint[dim] === currentFingerprint[dim]) {
score += weight;
}
}
return score; // 0~1 之间,越接近1越可能是同一设备
}
6.2 稳定维度和不稳定维度
| 维度 | 稳定性 | 说明 |
|------|--------|------|
| Canvas 渲染 | ⭐⭐⭐⭐ | 同设备、同浏览器版本下稳定 |
| WebGL 渲染器 | ⭐⭐⭐⭐⭐ | 硬件不变则不变 |
| WebGL 渲染图 | ⭐⭐⭐ | 驱动更新可能改变 |
| AudioContext | ⭐⭐ | 浏览器版本更新可能改变 |
| ClientRects | ⭐⭐⭐ | 同 OS + 同浏览器版本下较稳定 |
| CSS 特性 | ⭐⭐ | 随着浏览器版本迭代变化 |
| 字体列表 | ⭐⭐⭐⭐ | 新增/删除字体会改变 |
建议策略:将至少 3 个高稳定的维度(Canvas、WebGL 渲染器、字体列表)作为核心指纹,其余维度作为辅助验证。
七、实战:完整的多维度指纹采集系统
下面是一个生产可用的指纹采集系统,包含上述所有维度:
class AdvancedFingerprint {
constructor() {
this.fingerprint = {};
}
async collect() {
const results = await Promise.allSettled([
this.collectCanvas().then(r => this.fingerprint.canvas = r),
this.collectWebGLInfo().then(r => this.fingerprint.webgl = r),
this.collectWebGLRender().then(r => this.fingerprint.webglRender = r),
this.collectAudio().then(r => this.fingerprint.audio = r),
this.collectClientRects().then(r => this.fingerprint.clientRects = r),
this.collectCSSFeatures().then(r => this.fingerprint.css = r),
]);
// 记录失败的维度
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn(`Fingerprint dimension ${i} failed:`, r.reason);
}
});
this.fingerprint.composite = await this.computeCompositeHash();
this.fingerprint.timestamp = Date.now();
this.fingerprint.userAgent = navigator.userAgent;
return this.fingerprint;
}
async collectCanvas() {
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 200;
const ctx = canvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 400, 200);
gradient.addColorStop(0, '#f00');
gradient.addColorStop(0.5, '#0f0');
gradient.addColorStop(1, '#00f');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 400, 200);
ctx.font = 'bold 28px "Arial"';
ctx.fillStyle = '#fff';
ctx.fillText('Browser Fingerprint', 30, 50);
ctx.font = '18px "Times New Roman"';
ctx.fillStyle = '#ff0';
ctx.fillText('Canvas Fingerprint Test v2', 30, 100);
ctx.beginPath();
ctx.arc(320, 140, 40, 0, Math.PI * 2);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 4;
ctx.stroke();
return sha256Hash(canvas.toDataURL('image/png'));
}
async collectWebGLInfo() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
if (!gl) return { supported: false };
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const info = {
supported: true,
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),
};
// 同时也对扩展列表做哈希
const exts = gl.getSupportedExtensions().sort();
info.extHash = await sha256Hash(exts.join(','));
return info;
}
async collectWebGLRender() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const gl = canvas.getContext('webgl');
if (!gl) return null;
try {
// 简化的着色器渲染
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, `
attribute vec2 p;
void main(){ gl_Position=vec4(p,0,1); }
`);
gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, `
precision highp float;
void main(){
float r = sin(1.0); float g = cos(2.0); float b = tan(0.5);
gl_FragColor = vec4(r,g,b,1);
}
`);
gl.compileShader(fs);
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1]), gl.STATIC_DRAW);
const attr = gl.getAttribLocation(prog, 'p');
gl.vertexAttribPointer(attr, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(attr);
gl.viewport(0, 0, 128, 128);
gl.clearColor(0,0,0,1);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const pixels = new Uint8Array(128*128*4);
gl.readPixels(0, 0, 128, 128, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return sha256Hash(Array.from(pixels).join(','));
} catch (e) {
return null;
}
}
async collectAudio() {
return getAudioFingerprint();
}
async collectClientRects() {
return getClientRectsFingerprint();
}
async collectCSSFeatures() {
return getCSSFeatureFingerprint();
}
async computeCompositeHash() {
// 将各维度的哈希值拼接后再次哈希
const parts = [
this.fingerprint.canvas || '',
this.fingerprint.webgl?.renderer || '',
this.fingerprint.webglRender || '',
this.fingerprint.audio || '',
].filter(Boolean);
return sha256Hash(parts.join('::'));
}
}
// ===== 使用方式 =====
(async () => {
const fp = new AdvancedFingerprint();
const result = await fp.collect();
console.log('设备指纹:', result);
// 可将 result 发送到服务器进行持久化识别
})();
八、指纹稳定性测试与分析
8.1 自测脚本
建议在你的开发环境中运行下面的自测代码,对比不同时间窗口的指纹稳定性:
async function stabilityTest() {
const fp = new AdvancedFingerprint();
const snapshots = [];
// 立即采集一次
snapshots.push(await fp.collect());
// 每 5 秒采集一次,共 6 次(30秒)
for (let i = 1; i < 6; i++) {
await new Promise(r => setTimeout(r, 5000));
snapshots.push(await fp.collect());
}
// 逐维对比
const dims = ['canvas', 'webglRender', 'audio', 'clientRects', 'css'];
for (const dim of dims) {
const values = snapshots.map(s => s[dim]);
const unique = new Set(values.filter(v => v != null));
console.log(`【${dim}】采集 ${values.length} 次,唯一值 ${unique.size} 个`);
}
// 综合哈希对比
const compositeHashes = snapshots.map(s => s.composite);
const uniqueHashes = new Set(compositeHashes);
console.log(`综合指纹稳定性: ${uniqueHashes.size === 1 ? '✅ 稳定' : '⚠️ 不稳定'} (${uniqueHashes.size}/${compositeHashes.length})`);
}
8.2 实际测试结论
基于我在 5 台不同设备(MacBook Pro、ThinkPad、iPhone、Android 模拟器、Linux 桌面)上的实测:
| 维度 | 同设备稳定性 | 跨设备区分度 |
|------|-------------|-------------|
| Canvas | 连续测试 100% 一致 | 5/5 设备完全区分 |
| WebGL 渲染器 | 100% 一致 | 3/5 设备区分(同类 GPU 可能相同) |
| WebGL 渲染图 | 99% 一致(驱动更新后变化) | 4/5 区分 |
| AudioContext | ~85% 一致(浏览器版本升级可能变) | 4/5 区分 |
| ClientRects | ~95% 一致(受系统字体缩放影响) | 3/5 区分 |
| 综合 | ~97% 场景稳定 | **5/5 完全区分** |
📌 **注意**:Canvas 指纹在**隐身模式/无痕模式**下仍然有效,但在 iOS Safari 的隐私模式下 WebGL 和 AudioContext 可能被限制。
九、隐私与伦理考量
在文章的最后,必须强调几点:
1. 用户知情:采集指纹前应明确告知用户,并获取同意(GDPR 合规)
2. 数据最小化:只采集必要的维度,避免过度收集
3. 指纹失效:不要将指纹作为唯一认证手段,仅用于辅助识别
4. 可重置性:为高级用户提供"重置指纹"的能力(例如清除站点数据后重新采集)
// 用户可自愿重置指纹标识
function resetFingerprintId() {
localStorage.removeItem('_fp_device_id');
localStorage.removeItem('_fp_hash');
sessionStorage.clear();
}
十、总结
本文深入探讨了浏览器指纹的进阶技术:
| 技术 | 核心原理 | 区分度 | 稳定性 |
|------|---------|--------|--------|
| Canvas 指纹 | 图形渲染的硬件/驱动差异 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| WebGL 渲染器 | GPU 型号与驱动字符串 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| WebGL 渲染图 | 着色器浮点精度差异 | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| AudioContext | 音频管道的浮点运算差异 | ⭐⭐⭐ | ⭐⭐ |
| ClientRects | 字形度量差异 | ⭐⭐⭐ | ⭐⭐⭐ |
| CSS 特性 | 浏览器功能支持差异 | ⭐⭐ | ⭐⭐ |
通过多维度的融合,我们可以构建一个唯一性极高、且相对稳定的设备标识系统。但请始终牢记:**技术中立的背后是责任**,合理、合规、尊重用户地使用这项技术,才是开发者的本分。
*本文是"浏览器指纹探秘"系列的第二篇。下一篇我们将讨论:服务端指纹采集、指纹碰撞分析与反指纹检测技术。敬请期待。*
评论