浏览器指纹基础:Canvas指纹、WebGL指纹、AudioContext指纹、Font指纹

说到浏览器指纹,很多人第一反应是「不就是收集点浏览器信息吗?」。其实没那么简单。我做了这么多年设备指纹,可以负责任地告诉你:浏览器指纹是设备指纹体系里最基础、也最容易被篡改的一层。

为什么?因为浏览器暴露给 JavaScript 的 API 太多了。每个 API 都能提取出一些「看起来一样、实际上有细微差异」的数据。把这些数据组合起来,就能形成一串几乎唯一的标识。

这一章,我们重点拆解四个最核心的指纹维度:Canvas、WebGL、AudioContext 和 Font。它们各有各的脾气,也各有各的坑。

核心观点:浏览器指纹不是靠单一特征,而是靠「特征组合」来区分设备。单个特征可能重复,但四个维度叠加后,碰撞概率极低。
浏览器指纹四大核心维度 浏览器指纹 Canvas 指纹 WebGL 指纹 AudioContext 指纹 Font 指纹 各维度提取的特征: • Canvas:图形渲染差异(抗锯齿、子像素偏移) • WebGL:GPU 型号、驱动版本、渲染能力 • AudioContext:音频处理链路的细微时延差异 • Font:系统已安装字体的列表与渲染度量

Canvas 指纹:最经典的「图形指纹」

Canvas 指纹的原理其实很简单。你让浏览器画一张图——画点文字、画些几何形状、加点颜色渐变。然后你把画布上的像素数据读出来,算一个哈希值。

关键来了:不同设备、不同浏览器、甚至不同显卡驱动,渲染同一段 Canvas 指令,结果会有细微差异。抗锯齿算法的实现不同、子像素渲染的偏移不同、字体渲染引擎的版本不同……这些差异都会体现在像素数据里。

我举个例子。你在 Chrome 和 Firefox 上分别画一段「Hello, World!」文字,然后读取像素。两个结果大概率不一样。这就是 Canvas 指纹的威力。

实战经验:我个人习惯在 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);
}
注意:Canvas 指纹有一个致命弱点——浏览器可以禁用 Canvas 的 toDataURL 或 getImageData 方法。一旦被禁用,你就拿不到像素数据了。我曾经在某个项目中遇到过这种情况,后来不得不改用 WebGL 作为备选方案。

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()
  };
}
关键点:WebGL 指纹的稳定性极高。只要不换硬件、不更新驱动,同一台设备的 WebGL 指纹几乎不会变化。但要注意——部分浏览器在隐私模式下会屏蔽 WebGL 信息。

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);
  });
}
避坑指南:我曾经在 iOS Safari 上踩过一个坑——AudioContext 在用户交互之前无法创建。解决方案是:在用户点击页面时「预热」一个 AudioContext,然后在需要采集时复用。否则你会得到一个「AudioContext was not allowed to start」的错误。

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'];
}
注意:Font 指纹有一个明显的局限性——用户可以通过安装或卸载字体来改变指纹。而且部分浏览器(如 Firefox)限制了 JavaScript 获取字体列表的能力。在实际项目中,我通常把 Font 指纹作为辅助维度,而不是主维度。

四个维度的组合策略

单独看每个维度,都有各自的弱点。但把它们组合起来,效果就完全不一样了。

我个人习惯的做法是:

  1. 优先采集 Canvas 和 WebGL——这两个维度稳定性最高,区分度最好
  2. AudioContext 作为补充——在 Canvas/WebGL 被屏蔽时,AudioContext 能提供额外的区分度
  3. Font 作为辅助验证——用于确认操作系统和语言环境,但不作为主标识
  4. 给每个维度分配权重——Canvas 和 WebGL 各占 35%,AudioContext 占 20%,Font 占 10%

这样组合下来,即使某个维度被篡改或屏蔽,其他维度仍然能维持指纹的稳定性。说白了,这就是「不要把鸡蛋放在同一个篮子里」的工程实践。