Canvas、Audio 和 WebGL:对指纹识别技术的深入分析
2025/10/28

网站早已学会不仅仅通过cookie来识别访客。即使您清除浏览记录,启用隐身模式并更改IP,您的浏览器仍然会泄露您的数字指纹。这个隐藏的标识符是由许多技术系统特征构建而成的。它在您每次访问时跟随您。
一个浏览器指纹是有关您的设备和软件环境的数据组合。正如没有两片相同的雪花,几乎也没有两个完全相同的浏览器。单个参数(比如浏览器版本或屏幕分辨率)出现在成千上万的用户中。但许多此类细节的组合创造了一个独特的资料。更糟糕的是,指纹不是存储在您的设备上:它是由网站“即时”计算的。因此,即使您清除cookie或使用隐身模式,指纹也不会消失:每次脚本都会重新收集相同的特征。
除了简单的指纹组件如用户代理商、时区或浏览器语言外,还有三种高级技术可以获取它:Canvas、AudioContext和WebGL。所有三种技术都利用浏览器内置的图形和音频功能来提取关于您的硬件和软件的信息。但它们究竟是如何工作的?为什么它们能揭示设备之间几乎不可察觉的差异?让我们来细分一下。
网站早已学会不仅仅通过cookie来识别访客。即使您清除浏览记录,启用隐身模式并更改IP,您的浏览器仍然会泄露您的数字指纹。这个隐藏的标识符是由许多技术系统特征构建而成的。它在您每次访问时跟随您。
一个浏览器指纹是有关您的设备和软件环境的数据组合。正如没有两片相同的雪花,几乎也没有两个完全相同的浏览器。单个参数(比如浏览器版本或屏幕分辨率)出现在成千上万的用户中。但许多此类细节的组合创造了一个独特的资料。更糟糕的是,指纹不是存储在您的设备上:它是由网站“即时”计算的。因此,即使您清除cookie或使用隐身模式,指纹也不会消失:每次脚本都会重新收集相同的特征。
除了简单的指纹组件如用户代理商、时区或浏览器语言外,还有三种高级技术可以获取它:Canvas、AudioContext和WebGL。所有三种技术都利用浏览器内置的图形和音频功能来提取关于您的硬件和软件的信息。但它们究竟是如何工作的?为什么它们能揭示设备之间几乎不可察觉的差异?让我们来细分一下。
内容
Canvas指纹识别
浏览器中的Canvas API允许通过JavaScript绘制图形。这种功能被秘密用于获取指纹。页面上的脚本创建一个不可见的<canvas>元素并进行一系列的绘图命令(例如,渲染文本、几何图形、添加阴影),然后将结果图像读取为像素数组。由于硬件和软件的差异,每台设备在最终的像素输出上会产生微小的变化。然后从这些数据中计算出一个哈希值,并用作唯一的用户标识符。
该过程可以如下所描述的步骤逐步进行:
Canvas创建。访问的网站在页面中动态插入一个
<canvas>元素(通常在屏幕外或隐藏)。绘图。JS代码将测试图形渲染到该canvas中。通常,它会以一种不常见的字体绘制一串文本,添加彩色形状、线条和效果(渐变、阴影)。
读取像素。绘图后,脚本调用
toDataURL()(或类似方法)以获得结果的二进制表示。哈希计算。获取的字符串(或像素数组)通过哈希函数处理。输出的哈希代码被发送到服务器,并用作指纹。
例如,简化一下:让我们在Canvas上绘制单词“指纹”并计算一个简单的哈希:
let canvas = document.createElement("canvas"); let ctx = canvas.getContext("2d"); canvas.width = 200; canvas.height = 50; ctx.textBaseline = "top"; ctx.font = "20px Arial"; ctx.fillStyle = "#f60"; ctx.fillText("指纹", 10, 10); let data = canvas.toDataURL(); let hash = 0; for (let i = 0; i < data.length; i++) { hash = (hash << 5) - hash + data.charCodeAt(i); hash |= 0; } console.log("Canvas hash:", hash);
这段代码将输出一个32位数字(由于溢出可能为负),这取决于浏览器如何渲染文本。使用其他浏览器(如我们的例子中是Opera)结果可能会有所不同(尽管有时候会相同,但这很少见)。在另一台PC上,结果更可能不同,尽管在边缘情况下可能会出现一致性。
下面的屏幕截图显示,即使在同一台PC上,但在不同浏览器中,哈希值也不同:在第一个案例中我们在Chrome中渲染了“指纹”这个词,在第二次是在Opera中。您可以在自己的设备上重复此操作。


为什么它们不同?并不是因为文本“指纹”本身:对于人眼来说视觉上相同。差异出现在渲染层:字体提示、消除锯齿、栅格化算法以及对像素网格的细微字形调整由不同的操作系统和浏览器以不同方式处理。加上GPU和驱动程序上的差异,每个设备在最终图像中引入了微小的失真。在一个像素上字符边缘稍微光一点,在另一个像素上则暗一些,某些地方字形被以不同的平滑度处理。这些微小的不同之处导致了不同的哈希,即使图像在视觉上看起来相同。
网站尝试进一步扩大差异性。它们可能会使用包含完整字母表和多个符号的特制字符串以触发多种渲染代码路径;例如,一个几乎包含所有拉丁字母的短语:“Cwm fjordbank glyphs mute quiz.”。它们还可能在文本上绘制彩色矩形、渐变或阴影—这都是为了提取更多独特的像素级别的细节。Canvas指纹识别的最终结果是前述的哈希字符串,但形式更复杂(例如,e3d52382d0…)。这个哈希可以稳定地识别该设备,并且在重复访问时,除非环境发生变化,否则同一浏览器将生成相同的哈希。
因此,Canvas为网站提供了一个强大的跟踪工具。在没有用户同意的情况下,网站收集了系统相关的“渲染”。具有相同GPU和软件的设备可能会产生匹配的Canvas指纹,但找到两个这样的“孪生”设备极为罕见。通常,GPU规格、字体和软件的组合足够独特以进行跟踪。
AudioContext指纹识别
下一种方法是音频指纹识别,其中Web音频API成为唯一“声音”标识符的来源。这听起来可能很奇怪:网站不请求麦克风访问,也不播放可听音频。这更为微妙。脚本在浏览器内生成和处理音频信号,然后提取间接反映系统特性的数字指标。结果是一个类似Canvas哈希的稳定标识符,但基于音频。
宽泛地介绍其工作原理:
AudioContext。脚本创建一个隐藏的音频上下文(通常是
OfflineAudioContext)——一个可以在内存中处理音频而不输出到扬声器的虚拟“声卡”。信号生成。振荡器(
OscillatorNode)生成固定频率的音调(例如,为三角波生成1000 Hz)。振荡器以编程方式合成音调而不是加载音频文件。效果处理。为了放大硬件差异,信号通过音频效果进行路由——通常是一个压缩器(
DynamicsCompressorNode)“压缩”波形。“通过配置阈值、比率、释放时间和其他参数,出现细微的波形变化。渲染与读取。虚拟音频上下文在内存中快速渲染指定的音频片段。渲染后,脚本获得一个采样值缓冲区(浮点数数组)。例如,在44,100 Hz和约113 ms的持续时间下,您可能会获得约5,000个样本。
指纹计算。采样数组被简化为一个简洁的数字。一种简单的方法是所有样本的绝对值求和并取最高显著数位。该数字成为音频指纹。
下面是您可以在浏览器控制台中运行的代码示例以生成音频指纹:
(async () => { const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; const ctx = new AC(1, 5000, 44100); const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = 1000; const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -50; comp.knee.value = 40; comp.ratio.value = 12; comp.attack.value = 0; comp.release.value = 0.25; osc.connect(comp); comp.connect(ctx.destination); osc.start(0); const rendered = await ctx.startRendering(); const samples = rendered.getChannelData(0); let acc = 0; for (let i = 0; i < samples.length; i++) acc += Math.abs(samples[i]); const demo = Math.round(acc * 1e6) / 1e6; const buf = samples.buffer.slice( samples.byteOffset, samples.byteOffset + samples.byteLength ); const hashBuf = await crypto.subtle.digest('SHA-256', buf); const hashHex = Array.from(new Uint8Array(hashBuf)) .map(b => b.toString(16).padStart(2, '0')) .join(''); console.log('Audio demo sum:', demo); console.log('Audio SHA-256 :', hashHex); })();
从下方的截图可以看到,相同PC在不同浏览器中的音频指纹不会变化(在我们的例子中产生了数字953.152941)。您可以进行比较并在自己的PC上运行代码。结果将是相同的,但当然会有您自己的指纹。


因此,每个设备通常都有其自己的数字,音频哈希。
浏览器开发人员早已意识到此方法的危险。Apple是首批提供保护的公司之一:从Safari 17开始,在私密模式下,AudioContext API故意在生成的声音中注入小的随机性。由于此更改,相同的Safari在不同会话中可以生成不同的音频哈希。大多数其他浏览器仍允许无重大障碍地进行音频指纹识别(如上例所示)。结合Canvas和WebGL数据,音频指纹识别显著增加了设备的可识别性。
WebGL指纹识别
HTML5 WebGL是一种用于在浏览器中渲染3D的图形API(通过带WebGL上下文的<canvas>)。它也已成为捕获设备指纹的工具。如果Canvas指纹识别揭示了2D渲染中的差异,WebGL就更深入地挖掘到了GPU本身。这里的识别可能性更为广泛。从WebGL数据中,您几乎可以直接了解GPU型号和驱动程序,小的渲染细节甚至可以区分具有相同GPU的两个设备。
典型的WebGL指纹场景如下:
WebGL初始化。脚本创建一个WebGL上下文(例如,
canvas.getContext("webgl2"))。在此步骤中,脚本已经可以获取一些环境细节:GPU名称(供应商/渲染器)、驱动程序版本、支持的扩展等。场景渲染。然后一个隐藏的canvas渲染一个3D场景或特殊的基本体。通常会绘制一组选用的图形与着色器效果、照明和纹理—足以测试图形管线的不同部分。
参数收集。渲染后,脚本读取生成的图像(通过
gl.readPixels)并查询一组WebGL参数:支持的扩展、最大纹理大小、着色器精度、RENDERER/VENDOR字符串等。这些数据形成了图形系统的“硬件快照”。哈希生成。收集的数字和字符串被组合并进行哈希(例如通过SHA-256算法)。生成的哈希成为WebGL指纹。然后它被发送到服务器,网站可以利用它用于当前和未来的访问识别。
以下是生成这样一个哈希的示例:
(async () => { const cv = document.createElement('canvas'); cv.width = 400; cv.height = 200; const gl = cv.getContext('webgl2', {antialias:true}) || cv.getContext('webgl', {antialias:true}); if (!gl) { console.log('WebGL недоступен'); return; } const info = {}; const dbg = gl.getExtension('WEBGL_debug_renderer_info'); if (dbg) { info.vendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL); info.renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); } else { info.vendor = '(masked)'; info.renderer = '(masked)'; } info.version = gl.getParameter(gl.VERSION); info.glsl = gl.getParameter(gl.SHADING_LANGUAGE_VERSION); info.maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE); const vs = ` attribute vec2 p; void main(){ gl_Position = vec4(p,0.0,1.0); } `; const fs = ` precision highp float; uniform vec2 u_res; float h(vec2 v){ float s = sin(dot(v, vec2(12.9898,78.233))) * 43758.5453; return fract(s); } void main(){ vec2 uv = gl_FragCoord.xy / u_res; float r = h(uv + vec2(0.11,0.21)); float g = h(uv*1.3 + vec2(0.31,0.41)); float b = h(uv*1.7 + vec2(0.51,0.61)); gl_FragColor = vec4(pow(vec3(r,g,b)*(0.6+0.4*uv.x), vec3(1.1)), 1.0); } `; function sh(type, src){ const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)||'shader error'); return s; } const pr = gl.createProgram(); gl.attachShader(pr, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(pr); if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr)||'link error'); gl.useProgram(pr); const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const ures = gl.getUniformLocation(pr, 'u_res'); gl.uniform2f(ures, gl.drawingBufferWidth, gl.drawingBufferHeight); gl.viewport(0,0,gl.drawingBufferWidth, gl.drawingBufferHeight); gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight; const px = new Uint8Array(w*h*4); gl.readPixels(0,0,w,h, gl.RGBA, gl.UNSIGNED_BYTE, px); const enc = new TextEncoder(); const meta = enc.encode(JSON.stringify(info)); const full = new Uint8Array(meta.length + px.length); full.set(meta, 0); full.set(px, meta.length); const hashBuf = await crypto.subtle.digest('SHA-256', full.buffer); const hex = Array.from(new Uint8Array(hashBuf)) .map(b=>b.toString(16).padStart(2,'0')).join(''); let hi = 0>>>0, lo = 0>>>0; for (let i=0;i<px.length;i+=16){ const a = px[i] | (px[i+1]<<8) | (px[i+2]<<16) | (px[i+3]<<24); hi = ((hi ^ a) + 0x9e3779b9) >>> 0; lo = ((lo ^ ((a<<7)|(a>>>25))) + 0x85ebca6b) >>> 0; hi ^= (hi<<13)>>>0; lo ^= (lo<<15)>>>0; } const sample64 = ('00000000'+hi.toString(16)).slice(-8)+('00000000'+lo.toString(16)).slice(-8); console.log('WebGL vendor :', info.vendor); console.log('WebGL renderer:', info.renderer); console.log('WebGL version :', info.version, '| GLSL:', info.glsl); console.log('MAX_TEXTURE_SIZE:', info.maxTex); console.log('SHA-256(meta+pixels):', hex); console.log('Sample64:', sample64); })();
最终我们得到了什么:

WebGL揭示了哪些差异?首先是GPU标识符。例如,集成的Intel Graphics和独立的NVIDIA GeForce或AMD Radeon具有不同的功能、VRAM大小和驱动程序,这在上下文参数中得到反映。其次是相同型号中的差异。即使是据说相同的GPU型号在性能和计算精度上也有细微的个体差异。WebGL也能揭示这些:测量着色器执行时间或渲染输出中的微小像素伪影将暴露出差异。最后,浏览器本身也很重要。不同的渲染引擎(Blink、WebKit、Gecko)以不同方式执行WebGL调用,可能会产生略微不同的渲染结果。下面的截图显示了当我们在Opera中生成相同的指纹时出现的差异。

WebGL指纹通常与Canvas和Audio一起使用进行分层跟踪,但即使单独使用,它的信息量也足够大,可以揭示出关于系统的许多信息。
如何保护自己免受指纹识别
完全消除浏览器指纹识别极其困难,因为有太多的泄漏通道。然而,一些措施可以减少您的独特性:
特殊模式和浏览器。Tor浏览器强制实施严格保护:所有用户共享相同的特征集,如克隆。Canvas和WebGL在那里要么被禁用,要么返回平均值。缺点是许多网络服务会崩溃。Brave在激进保护模式下也会阻止常见的跟踪措施。Safari在私人模式下向音频数据添加噪声以隐藏指纹。
阻止和伪造。有一些扩展程序,例如CanvasBlocker,阻止脚本读取Canvas或用一个随机图像进行伪造。AudioContext的插件也存在。然而,使用这些附加功能的用户相对较少(约~100k)。一个看到完全空白Canvas或杂乱噪声哈希的网站会怀疑有问题。与其隐藏,您可能会更加突出。
环境统一。另一种方法是使指纹成为非唯一但通用的—例如,在云服务或虚拟机中运行浏览器,这样所有客户端都有相同的配置。一些反欺诈系统就是这样做的:可疑用户在隔离的“浏览器模拟器”中运行,其指纹被匿名化。但显然这对于日常浏览来说很不方便。
控制伪造。最好的解决方案之一是反检测浏览器,允许进行细粒度的环境配置。它们让您决定网站看到的Canvas或WebGL是什么。一个完美的例子是Octo Browser。它提供主动伪造:它向Canvas和音频中添加噪声,伪造WebGL和其他指纹参数,并生成类似真实设备的配置。反检测浏览器尝试模拟典型的浏览器,而不是随机更改所有内容。一个好的反检测浏览器使每个配置文件看起来似乎是独特的,而不会在数百万其他用户中显得突出。
如果您希望在网上保持真正的匿名,您需要不同的浏览器和设备,始终保持软件更新,禁用不必要的插件,等等,但即便如此,您也只能略微提高与人群融为一体的机会。更好且更实用的替代方案是使用专业工具,如Octo Browser。反检测浏览器可以有意义且持续地伪造您的指纹,帮助您真正保持在线隐私。
Canvas指纹识别
浏览器中的Canvas API允许通过JavaScript绘制图形。这种功能被秘密用于获取指纹。页面上的脚本创建一个不可见的<canvas>元素并进行一系列的绘图命令(例如,渲染文本、几何图形、添加阴影),然后将结果图像读取为像素数组。由于硬件和软件的差异,每台设备在最终的像素输出上会产生微小的变化。然后从这些数据中计算出一个哈希值,并用作唯一的用户标识符。
该过程可以如下所描述的步骤逐步进行:
Canvas创建。访问的网站在页面中动态插入一个
<canvas>元素(通常在屏幕外或隐藏)。绘图。JS代码将测试图形渲染到该canvas中。通常,它会以一种不常见的字体绘制一串文本,添加彩色形状、线条和效果(渐变、阴影)。
读取像素。绘图后,脚本调用
toDataURL()(或类似方法)以获得结果的二进制表示。哈希计算。获取的字符串(或像素数组)通过哈希函数处理。输出的哈希代码被发送到服务器,并用作指纹。
例如,简化一下:让我们在Canvas上绘制单词“指纹”并计算一个简单的哈希:
let canvas = document.createElement("canvas"); let ctx = canvas.getContext("2d"); canvas.width = 200; canvas.height = 50; ctx.textBaseline = "top"; ctx.font = "20px Arial"; ctx.fillStyle = "#f60"; ctx.fillText("指纹", 10, 10); let data = canvas.toDataURL(); let hash = 0; for (let i = 0; i < data.length; i++) { hash = (hash << 5) - hash + data.charCodeAt(i); hash |= 0; } console.log("Canvas hash:", hash);
这段代码将输出一个32位数字(由于溢出可能为负),这取决于浏览器如何渲染文本。使用其他浏览器(如我们的例子中是Opera)结果可能会有所不同(尽管有时候会相同,但这很少见)。在另一台PC上,结果更可能不同,尽管在边缘情况下可能会出现一致性。
下面的屏幕截图显示,即使在同一台PC上,但在不同浏览器中,哈希值也不同:在第一个案例中我们在Chrome中渲染了“指纹”这个词,在第二次是在Opera中。您可以在自己的设备上重复此操作。


为什么它们不同?并不是因为文本“指纹”本身:对于人眼来说视觉上相同。差异出现在渲染层:字体提示、消除锯齿、栅格化算法以及对像素网格的细微字形调整由不同的操作系统和浏览器以不同方式处理。加上GPU和驱动程序上的差异,每个设备在最终图像中引入了微小的失真。在一个像素上字符边缘稍微光一点,在另一个像素上则暗一些,某些地方字形被以不同的平滑度处理。这些微小的不同之处导致了不同的哈希,即使图像在视觉上看起来相同。
网站尝试进一步扩大差异性。它们可能会使用包含完整字母表和多个符号的特制字符串以触发多种渲染代码路径;例如,一个几乎包含所有拉丁字母的短语:“Cwm fjordbank glyphs mute quiz.”。它们还可能在文本上绘制彩色矩形、渐变或阴影—这都是为了提取更多独特的像素级别的细节。Canvas指纹识别的最终结果是前述的哈希字符串,但形式更复杂(例如,e3d52382d0…)。这个哈希可以稳定地识别该设备,并且在重复访问时,除非环境发生变化,否则同一浏览器将生成相同的哈希。
因此,Canvas为网站提供了一个强大的跟踪工具。在没有用户同意的情况下,网站收集了系统相关的“渲染”。具有相同GPU和软件的设备可能会产生匹配的Canvas指纹,但找到两个这样的“孪生”设备极为罕见。通常,GPU规格、字体和软件的组合足够独特以进行跟踪。
AudioContext指纹识别
下一种方法是音频指纹识别,其中Web音频API成为唯一“声音”标识符的来源。这听起来可能很奇怪:网站不请求麦克风访问,也不播放可听音频。这更为微妙。脚本在浏览器内生成和处理音频信号,然后提取间接反映系统特性的数字指标。结果是一个类似Canvas哈希的稳定标识符,但基于音频。
宽泛地介绍其工作原理:
AudioContext。脚本创建一个隐藏的音频上下文(通常是
OfflineAudioContext)——一个可以在内存中处理音频而不输出到扬声器的虚拟“声卡”。信号生成。振荡器(
OscillatorNode)生成固定频率的音调(例如,为三角波生成1000 Hz)。振荡器以编程方式合成音调而不是加载音频文件。效果处理。为了放大硬件差异,信号通过音频效果进行路由——通常是一个压缩器(
DynamicsCompressorNode)“压缩”波形。“通过配置阈值、比率、释放时间和其他参数,出现细微的波形变化。渲染与读取。虚拟音频上下文在内存中快速渲染指定的音频片段。渲染后,脚本获得一个采样值缓冲区(浮点数数组)。例如,在44,100 Hz和约113 ms的持续时间下,您可能会获得约5,000个样本。
指纹计算。采样数组被简化为一个简洁的数字。一种简单的方法是所有样本的绝对值求和并取最高显著数位。该数字成为音频指纹。
下面是您可以在浏览器控制台中运行的代码示例以生成音频指纹:
(async () => { const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; const ctx = new AC(1, 5000, 44100); const osc = ctx.createOscillator(); osc.type = 'triangle'; osc.frequency.value = 1000; const comp = ctx.createDynamicsCompressor(); comp.threshold.value = -50; comp.knee.value = 40; comp.ratio.value = 12; comp.attack.value = 0; comp.release.value = 0.25; osc.connect(comp); comp.connect(ctx.destination); osc.start(0); const rendered = await ctx.startRendering(); const samples = rendered.getChannelData(0); let acc = 0; for (let i = 0; i < samples.length; i++) acc += Math.abs(samples[i]); const demo = Math.round(acc * 1e6) / 1e6; const buf = samples.buffer.slice( samples.byteOffset, samples.byteOffset + samples.byteLength ); const hashBuf = await crypto.subtle.digest('SHA-256', buf); const hashHex = Array.from(new Uint8Array(hashBuf)) .map(b => b.toString(16).padStart(2, '0')) .join(''); console.log('Audio demo sum:', demo); console.log('Audio SHA-256 :', hashHex); })();
从下方的截图可以看到,相同PC在不同浏览器中的音频指纹不会变化(在我们的例子中产生了数字953.152941)。您可以进行比较并在自己的PC上运行代码。结果将是相同的,但当然会有您自己的指纹。


因此,每个设备通常都有其自己的数字,音频哈希。
浏览器开发人员早已意识到此方法的危险。Apple是首批提供保护的公司之一:从Safari 17开始,在私密模式下,AudioContext API故意在生成的声音中注入小的随机性。由于此更改,相同的Safari在不同会话中可以生成不同的音频哈希。大多数其他浏览器仍允许无重大障碍地进行音频指纹识别(如上例所示)。结合Canvas和WebGL数据,音频指纹识别显著增加了设备的可识别性。
WebGL指纹识别
HTML5 WebGL是一种用于在浏览器中渲染3D的图形API(通过带WebGL上下文的<canvas>)。它也已成为捕获设备指纹的工具。如果Canvas指纹识别揭示了2D渲染中的差异,WebGL就更深入地挖掘到了GPU本身。这里的识别可能性更为广泛。从WebGL数据中,您几乎可以直接了解GPU型号和驱动程序,小的渲染细节甚至可以区分具有相同GPU的两个设备。
典型的WebGL指纹场景如下:
WebGL初始化。脚本创建一个WebGL上下文(例如,
canvas.getContext("webgl2"))。在此步骤中,脚本已经可以获取一些环境细节:GPU名称(供应商/渲染器)、驱动程序版本、支持的扩展等。场景渲染。然后一个隐藏的canvas渲染一个3D场景或特殊的基本体。通常会绘制一组选用的图形与着色器效果、照明和纹理—足以测试图形管线的不同部分。
参数收集。渲染后,脚本读取生成的图像(通过
gl.readPixels)并查询一组WebGL参数:支持的扩展、最大纹理大小、着色器精度、RENDERER/VENDOR字符串等。这些数据形成了图形系统的“硬件快照”。哈希生成。收集的数字和字符串被组合并进行哈希(例如通过SHA-256算法)。生成的哈希成为WebGL指纹。然后它被发送到服务器,网站可以利用它用于当前和未来的访问识别。
以下是生成这样一个哈希的示例:
(async () => { const cv = document.createElement('canvas'); cv.width = 400; cv.height = 200; const gl = cv.getContext('webgl2', {antialias:true}) || cv.getContext('webgl', {antialias:true}); if (!gl) { console.log('WebGL недоступен'); return; } const info = {}; const dbg = gl.getExtension('WEBGL_debug_renderer_info'); if (dbg) { info.vendor = gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL); info.renderer = gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL); } else { info.vendor = '(masked)'; info.renderer = '(masked)'; } info.version = gl.getParameter(gl.VERSION); info.glsl = gl.getParameter(gl.SHADING_LANGUAGE_VERSION); info.maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE); const vs = ` attribute vec2 p; void main(){ gl_Position = vec4(p,0.0,1.0); } `; const fs = ` precision highp float; uniform vec2 u_res; float h(vec2 v){ float s = sin(dot(v, vec2(12.9898,78.233))) * 43758.5453; return fract(s); } void main(){ vec2 uv = gl_FragCoord.xy / u_res; float r = h(uv + vec2(0.11,0.21)); float g = h(uv*1.3 + vec2(0.31,0.41)); float b = h(uv*1.7 + vec2(0.51,0.61)); gl_FragColor = vec4(pow(vec3(r,g,b)*(0.6+0.4*uv.x), vec3(1.1)), 1.0); } `; function sh(type, src){ const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)||'shader error'); return s; } const pr = gl.createProgram(); gl.attachShader(pr, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(pr, sh(gl.FRAGMENT_SHADER, fs)); gl.linkProgram(pr); if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(pr)||'link error'); gl.useProgram(pr); const buf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(pr, 'p'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const ures = gl.getUniformLocation(pr, 'u_res'); gl.uniform2f(ures, gl.drawingBufferWidth, gl.drawingBufferHeight); gl.viewport(0,0,gl.drawingBufferWidth, gl.drawingBufferHeight); gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT); gl.drawArrays(gl.TRIANGLES, 0, 3); const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight; const px = new Uint8Array(w*h*4); gl.readPixels(0,0,w,h, gl.RGBA, gl.UNSIGNED_BYTE, px); const enc = new TextEncoder(); const meta = enc.encode(JSON.stringify(info)); const full = new Uint8Array(meta.length + px.length); full.set(meta, 0); full.set(px, meta.length); const hashBuf = await crypto.subtle.digest('SHA-256', full.buffer); const hex = Array.from(new Uint8Array(hashBuf)) .map(b=>b.toString(16).padStart(2,'0')).join(''); let hi = 0>>>0, lo = 0>>>0; for (let i=0;i<px.length;i+=16){ const a = px[i] | (px[i+1]<<8) | (px[i+2]<<16) | (px[i+3]<<24); hi = ((hi ^ a) + 0x9e3779b9) >>> 0; lo = ((lo ^ ((a<<7)|(a>>>25))) + 0x85ebca6b) >>> 0; hi ^= (hi<<13)>>>0; lo ^= (lo<<15)>>>0; } const sample64 = ('00000000'+hi.toString(16)).slice(-8)+('00000000'+lo.toString(16)).slice(-8); console.log('WebGL vendor :', info.vendor); console.log('WebGL renderer:', info.renderer); console.log('WebGL version :', info.version, '| GLSL:', info.glsl); console.log('MAX_TEXTURE_SIZE:', info.maxTex); console.log('SHA-256(meta+pixels):', hex); console.log('Sample64:', sample64); })();
最终我们得到了什么:

WebGL揭示了哪些差异?首先是GPU标识符。例如,集成的Intel Graphics和独立的NVIDIA GeForce或AMD Radeon具有不同的功能、VRAM大小和驱动程序,这在上下文参数中得到反映。其次是相同型号中的差异。即使是据说相同的GPU型号在性能和计算精度上也有细微的个体差异。WebGL也能揭示这些:测量着色器执行时间或渲染输出中的微小像素伪影将暴露出差异。最后,浏览器本身也很重要。不同的渲染引擎(Blink、WebKit、Gecko)以不同方式执行WebGL调用,可能会产生略微不同的渲染结果。下面的截图显示了当我们在Opera中生成相同的指纹时出现的差异。

WebGL指纹通常与Canvas和Audio一起使用进行分层跟踪,但即使单独使用,它的信息量也足够大,可以揭示出关于系统的许多信息。
如何保护自己免受指纹识别
完全消除浏览器指纹识别极其困难,因为有太多的泄漏通道。然而,一些措施可以减少您的独特性:
特殊模式和浏览器。Tor浏览器强制实施严格保护:所有用户共享相同的特征集,如克隆。Canvas和WebGL在那里要么被禁用,要么返回平均值。缺点是许多网络服务会崩溃。Brave在激进保护模式下也会阻止常见的跟踪措施。Safari在私人模式下向音频数据添加噪声以隐藏指纹。
阻止和伪造。有一些扩展程序,例如CanvasBlocker,阻止脚本读取Canvas或用一个随机图像进行伪造。AudioContext的插件也存在。然而,使用这些附加功能的用户相对较少(约~100k)。一个看到完全空白Canvas或杂乱噪声哈希的网站会怀疑有问题。与其隐藏,您可能会更加突出。
环境统一。另一种方法是使指纹成为非唯一但通用的—例如,在云服务或虚拟机中运行浏览器,这样所有客户端都有相同的配置。一些反欺诈系统就是这样做的:可疑用户在隔离的“浏览器模拟器”中运行,其指纹被匿名化。但显然这对于日常浏览来说很不方便。
控制伪造。最好的解决方案之一是反检测浏览器,允许进行细粒度的环境配置。它们让您决定网站看到的Canvas或WebGL是什么。一个完美的例子是Octo Browser。它提供主动伪造:它向Canvas和音频中添加噪声,伪造WebGL和其他指纹参数,并生成类似真实设备的配置。反检测浏览器尝试模拟典型的浏览器,而不是随机更改所有内容。一个好的反检测浏览器使每个配置文件看起来似乎是独特的,而不会在数百万其他用户中显得突出。
如果您希望在网上保持真正的匿名,您需要不同的浏览器和设备,始终保持软件更新,禁用不必要的插件,等等,但即便如此,您也只能略微提高与人群融为一体的机会。更好且更实用的替代方案是使用专业工具,如Octo Browser。反检测浏览器可以有意义且持续地伪造您的指纹,帮助您真正保持在线隐私。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。
随时获取最新的Octo Browser新闻
通过点击按钮,您同意我们的 隐私政策。


