深入 Canvas 指纹:为什么两台完全相同的 Chrome 会渲染出不同的像素
两台设备即便有相同的 Chrome、操作系统和 GPU,渲染出的 canvas 像素仍然不同。本文解析渲染管线为何具有非确定性、隐私噪声注入为何适得其反,以及 canvas 在现代检测体系中的定位。
Canvas 指纹自 2012 年起就已投入实际使用。其思路很直接:让浏览器渲染一段 2D 内容,读回像素,再做哈希。对于相同的指令,不同设备会产生略有差异的像素,而这些差异足够稳定,可以在多个会话之间识别出同一台设备。
这些都是众所周知的。人们了解较少的是这项技术为什么有效——为什么在完全相同的硬件上、完全相同的软件配置,仍会产生不同的像素输出。答案位于 GPU 驱动、字体渲染和浮点运算的交汇之处。
渲染管线并非确定性的
当 JavaScript 调用 context.fillText("Cwm fjord bank glyphs vext quiz", 4, 45) 时,Chrome 并不会自己渲染像素。它为 Skia(2D 图形库)生成指令,Skia 再向 GPU 加速的后端发出绘制命令(在 Windows 上通常是 ANGLE,在 macOS 上是 Metal,在容器中则是软件回退方案)。
在每一个步骤,微小的实现差异都会累积:
-
Skia 的字体子系统会选择一个字体族。如果没有安装 Arial,就会回退——而回退结果会因系统已安装的字体而异。
-
选定的字体会按请求的大小,使用 FreeType(Linux 上)、DirectWrite(Windows 上)或 Core Text(macOS 上)进行渲染。每个库的 hinting 算法各不相同。
-
应用子像素定位。正是在这里,相同的字体开始产生不同的像素——算法会把位置取整到分数级的像素偏移,而取整规则在各平台之间并不相同。
-
光栅化后的文本被合成到 canvas 上。在硬件加速的系统上,混合由 GPU 着色器完成;在回退模式下,则由 CPU 代码完成。
上述每一步都可能引入一到两个像素值的差异。单独看不可见。合起来则独一无二。
GPU 驱动版本主导指纹
两台搭载 Intel UHD Graphics 620 芯片的 Windows 11 机器,如果 GPU 驱动不同,就可能产生不同的 canvas 输出。Intel 每年会发布多次驱动更新,而每次更新都可能改变子像素滤波、伽马校正和文本抗锯齿。
这意味着 canvas 指纹的稳定性受限于驱动更新频率。用户升级显卡驱动后——往往是通过 Windows Update 悄无声息地完成——就会看到自己的 canvas 指纹发生变化。
检测系统通过把 canvas 指纹当作众多信号之一、而非独立标识符来应对这一点。当它发生变化、而其他信号(TLS 指纹、WebGL renderer、时区、已安装字体)保持稳定时,访客仍然会被识别出来。
Emoji 渲染是一座金矿
Canvas 指纹中辨识度最高的元素,往往不是文本或图形,而是 emoji。Emoji 渲染取决于操作系统附带的 emoji 字体——Windows 上是 Segoe UI Emoji,macOS 上是 Apple Color Emoji,Android 上是 Noto Color Emoji。
即便在同一个操作系统内,emoji 渲染也会随版本变化。Windows 10 对彩虹 emoji 的渲染与 Windows 11 不同。iOS 16 的咧嘴笑脸与 iOS 17 的抗锯齿也不一样。
现代 canvas 指纹脚本会专门渲染一系列 emoji,包括 Unicode 的新增字符。一个自称运行 iPhone Safari、却无法渲染 iOS 17 中新增 emoji 的访客,要么运行的是更旧的 iOS,要么在谎报平台。
自动化框架会产生独特的指纹
无头 Chrome 在没有 GPU 的 Docker 容器中运行时,会使用 SwiftShader 进行渲染。SwiftShader 产生的 canvas 输出内部自洽,但与任何硬件加速的 Chrome 都不同。
这种特征很容易辨认:异常干净的抗锯齿、渐变区域中特定的颜色值,以及与任何已知 GPU 驱动都不匹配的文本边缘。一个在 Linux 容器中的 Chrome 124 里与 SwiftShader 相匹配的 canvas 指纹,几乎肯定是自动化——没有真实用户会以这种方式运行浏览器。
Puppeteer-extra-stealth 试图通过拦截 toDataURL() 并返回被修改的数据来伪造 canvas 输出。但这种修改往往会引入自身的痕迹——在多个会话中重复出现的噪声模式,反而使这种反指纹的努力比什么都不做更容易被检测到。
为什么噪声注入效果不佳
Brave 等注重隐私的浏览器会向 canvas 输出注入噪声以阻止追踪。其思路是给像素值添加微小的随机变化,使同一台设备在每个会话中产生不同的指纹。
在实践中,噪声注入存在三个问题:
1. 噪声本身就是一种指纹。一个 canvas 输出在每次加载时都发生变化、但其他信号保持一致的访客,会被识别为注入噪声的浏览器。这本身就具有辨识度。
2. 噪声算法是稳定的。Brave 的实现会产生特定的噪声模式,与 GPU 渲染的自然方差并不匹配。检测系统能够区分看起来像注入噪声的 canvas 输出与看起来自然的输出。
3. 成熟的系统会合并样本。同一页面上的多次渲染,或跨会话的多次渲染,都可以取平均。如果噪声较小,平均值就会收敛到底层的确定性指纹。
Canvas 指纹能捕获什么
除了显而易见的视觉输出,canvas 指纹还能作为副产品提取出若干设备属性:
渲染耗时——canvas 渲染所需的时间揭示了 GPU 能力。配备独立 GPU 的 4K 桌面设备渲染速度快于中端笔记本电脑。
文本度量——measureText() 返回的精确像素宽度会因字体渲染而异。即便不进行渲染,文本宽度也可以用作指纹。
合成操作支持情况——某些浏览器或 GPU 配置支持扩展的合成模式。测试其支持情况即可揭示能力边界。
滤镜效果——应用于 canvas 的 CSS 风格滤镜在不同 GPU 上会产生不同的输出,尤其是模糊和投影滤镜。
Canvas 指纹在检测体系中的定位
单独使用时,canvas 指纹并不是一个可靠的标识符。驱动会变化。用户会切换浏览器。在注重隐私的人群中,噪声注入很常见。
它真正的强项在于交叉验证。当一个会话根据 cookie 或账户登录声称自己是回访用户时,canvas 指纹充当第二个信号——与历史指纹相匹配即可确认该声称,不匹配则标记为账户接管或会话劫持。
对于机器人检测,canvas 指纹擅长捕捉容器化的自动化。真实用户有真实的 GPU。真实的 GPU 会产生可辨认的渲染特征。任何渲染得像 SwiftShader、或像拙劣伪造的 canvas 的东西,都落入几乎肯定是机器人的类别。
这项 2012 年的技术在 2026 年依然有效,原因始终如一:渲染在每个环境内是确定性的,但几乎无法被完全伪造。每一次隐藏的尝试,都会留下这次尝试本身的痕迹。