在上一篇文章中,我们深入 Skia 图形库,解决了 Canvas 2D 的像素级物理噪声注入。然而,在风控中,Canvas 2D 只是前哨战,WebGL 才是真正绞杀指纹浏览器的重型武器。
WebGL 将 JavaScript 的触角直接伸向了底层的 GPU 硬件。风控系统不仅看你的图画得怎么样(渲染哈希),更会直接审问你的 GPU:“你是谁?你从哪里来?你能做什么?”
如果你只是修改了navigator.userAgent声称自己是 MacBook,但 WebGL 却大声报告“我的渲染器是 NVIDIA GeForce RTX 4090”,这种跨维度的逻辑撕裂,会让风控系统在 1 毫秒内将你击毙。
本文将摒弃水话,直插 Chromium 的 GPU 进程与 ANGLE 引擎心脏,拆解 WebGL 渲染器、厂商特征及扩展列表的底层伪造与屏蔽逻辑。
一、 认知重塑:WebGL 指纹的三维杀伤链
风控通过 WebGL 构建了三维一体的检测模型,任何一维的缺失或矛盾都会触发警报:
- 身份维:显卡的厂商和型号(
VENDOR/RENDERER)。这是最基础的硬件身份证明。 - 能力维:支持的扩展列表(
EXTENSIONS)和参数上限(MAX_TEXTURE_SIZE等)。不同型号的显卡能力天差地别。 - 行为维:实际渲染 3D 场景后的像素哈希与着色器计算精度。即“你说你是谁,你能不能画出符合你身份的画”。
劣质指纹浏览器往往只伪造了第一维,却忽略了第二和第三维,导致死无葬身之地。
二、 身份维斩首:拦截 GLGetString
当 JS 执行gl.getParameter(gl.RENDERER)时,数据是如何流经 Chromium 的?
- JS 层:调用 WebGL 绑定函数。
- Blink 层:
WebGLRenderingContextBase::getParameter()将请求通过 Mojo IPC 发送给 GPU 进程。 - GPU 进程:调用 ANGLE(Almost Native Graphics Layer Engine),ANGLE 将 OpenGL ES 标准调用翻译成底层操作系统的 API(Windows 的 D3D11,Mac 的 Metal,Linux 的 OpenGL)。
- 底层驱动:真正的 GPU 驱动返回字符串(如 “NVIDIA GeForce RTX 4090”)。
- 原路返回:字符串经 GPU 进程、Mojo 传回 Blink,最终返回给 JS。
最愚蠢的做法:在 JS 层 HookWebGLRenderingContext.prototype.getParameter。风控可以通过创建隐藏的iframe获取原生对象,或者检测函数的toString()瞬间识破。
最优雅的做法:在 Blink 层拦截。因为数据从 GPU 进程传回 Blink 时,已经是纯字符串,我们完全不需要让请求真的去跑一趟 GPU 进程。
实战:Blink 层的静态替换
精准坐标:third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
在这个几千行的大文件中,找到WebGLRenderingContextBase::getParameter方法。
ScriptValueWebGLRenderingContextBase::getParameter(ScriptState*script_state,GLenum pname){// ... 前置校验逻辑 ...// 【指纹浏览器拦截点】constauto&fp_config=FingerprintConfig::GetInstance();if(pname==GL_RENDERER){if(fp_config->HasOverride("webgl_renderer")){// 直接返回 C++ 字符串,不发送 Mojo 请求到 GPU 进程returnScriptValue::From(script_state,fp_config->GetString("webgl_renderer"));}}elseif(pname==GL_VENDOR){if(fp_config->HasOverride("webgl_vendor")){returnScriptValue::From(script_state,fp_config->GetString("webgl_vendor"));}}elseif(pname==GL_UNMASKED_RENDERER_WEBGL){// 必须同时拦截 DEBUG 扩展暴露的未屏蔽渲染器if(fp_config->HasOverride("webgl_renderer")){returnScriptValue::From(script_state,fp_config->GetString("webgl_renderer"));}}elseif(pname==GL_UNMASKED_VENDOR_WEBGL){if(fp_config->HasOverride("webgl_vendor")){returnScriptValue::From(script_state,fp_config->GetString("webgl_vendor"));}}// 兜底:走原始逻辑,向 GPU 进程发起真实查询returnGetParameterHelper(script_state,pname);}核心优势:
- 零延迟:省去了跨进程通信和底层驱动的查询时间,执行时序与真实环境无异(甚至更快,但风控极少通过 Renderer 的查询耗时来反推,因为受系统负载影响太大)。
- 绝对隐蔽:JS 层看到的依然是原生的
getParameter函数,没有任何 Hook 痕迹。
三、 能力维重塑:扩展列表与参数的裁剪
如果风控发现你声称自己是 “Apple M1”,但你的 WebGL 扩展列表里却包含WEBGL_compressed_texture_s3tc(这是 Windows D3D 特有的压缩格式),你立刻就会被标记为伪造。
扩展列表和能力参数是与显卡型号强绑定的。我们不能随意添加,只能基于模板裁剪。
1. 扩展列表的精准过滤
精准坐标:third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc
当 JS 调用gl.getSupportedExtensions()时,Chromium 会收集底层 GPU 支持的所有扩展,并返回一个数组。我们需要在这里加上黑名单/白名单过滤。
Vector<String>WebGLRenderingContextBase::SupportedExtensions(){// 原始逻辑:获取底层真实支持的扩展Vector<String>original_extensions=...;constauto&fp_config=FingerprintConfig::GetInstance();if(fp_config->HasOverride("webgl_extension_blacklist")){Vector<String>filtered_extensions;constauto&blacklist=fp_config->GetStringList("webgl_extension_blacklist");for(constauto&ext:original_extensions){// 如果扩展不在黑名单中,则保留if(!blacklist.Contains(ext)){filtered_extensions.push_back(ext);}}returnfiltered_extensions;}returnoriginal_extensions;}实战策略:在指纹浏览器的业务后台,根据你预设的RENDERER型号,维护一个对应的扩展白名单。如果用户选择了 “NVIDIA GTX 1060”,下发配置时就剔除 Mac 独占扩展;如果选择了 “Apple M1”,就剔除 D3D 特有扩展。
2. 极限参数的动态降级
风控还会查询MAX_TEXTURE_SIZE、MAX_RENDERBUFFER_SIZE等极限参数。如果你声称自己是低端集成显卡,但返回的MAX_TEXTURE_SIZE却是 16384(高端卡特征),就会露馅。
精准坐标:同样在WebGLRenderingContextBase::getParameter中,针对特定的pname进行拦截降级。
caseGL_MAX_TEXTURE_SIZE:if(fp_config->HasOverride("max_texture_size")){returnScriptValue::From(script_state,fp_config->GetInt("max_texture_size"));}// 兜底返回真实值break;四、 行为维对抗:ANGLE 层的深度介入
即使你完美伪造了身份和能力,风控还有终极杀招:让你画一个极其复杂的 3D 场景,然后读取像素哈希。
不同架构的 GPU(NVIDIA/AMD/Intel/Apple)在执行浮点运算、抗锯齿和着色器插值时,物理微差异是不可避免的。如果你的RENDERER是 Intel UHD Graphics 630,但画出来的图却完美符合 NVIDIA 的渲染特征,你依然会死。
这是最难的对抗点。我们无法在 Blink 层通过简单的字符串替换解决,必须深入GPU 进程。
1. WebGL 噪声注入的双重困境
对于 Canvas 2D,我们可以在SkPixmap导出时注入物理噪声。但 WebGL 的导出路径不同:
- toDataURL:与 Canvas 2D 共用部分编码链路,可以在编码前注入噪声。
- readPixels:JS 直接读取显存/内存中的原始 RGBA 缓冲区。
如果你只 HooktoDataURL,风控用readPixels校验,瞬间穿帮。
如果你 HookreadPixels,风控如果调用drawArrays后不读取,而是继续在此基础上绘制,你注入的噪声会被放大,导致画面肉眼可见的损坏。
2. 破局点:拦截 GPU 进程的 Command Buffer
Chromium 的渲染进程和 GPU 进程通过Command Buffer(命令缓冲区)通信。所有的 WebGL 指令最终都被序列化成命令,发往 GPU 进程执行。
实战策略:在 GPU 进程的GLES2DecoderImpl中拦截DoReadPixels。
精准坐标:gpu/command_buffer/service/gles2_cmd_decoder.cc
当 GPU 进程收到读取像素的命令时,数据已经从显卡渲染完毕并读入了系统内存。这是注入噪声的最佳时机。
error::ErrorGLES2DecoderImpl::HandleReadPixels(uint32_timmediate_data_size,constvolatilevoid*cmd_data){// 1. 执行真实的 GPU 像素读取error::Error err=DoReadPixels(...);if(err==error::kNoError&&FingerprintConfig::GetInstance()->IsWebGLNoiseEnabled()){intprofile_seed=FingerprintConfig::GetInstance()->GetWebGLSeed();uint8_t*pixels=static_cast<uint8_t*>(dst_pixels);// 2. 对读取回的缓冲区注入与 Canvas 2D 类似的物理噪声// 注意:必须严格遵守 pack_alignment 对齐规则,否则会导致图像错位intpack_alignment=state_.pack_alignment;introw_bytes=width*4;intpadded_row_bytes=(row_bytes+pack_alignment-1)&~(pack_alignment-1);for(inty=0;y<height;++y){uint8_t*row=pixels+y*padded_row_bytes;for(intx=0;x<width;++x){uint8_t*pixel=row+x*4;// 调用前文定义的稳定噪声生成器intnoise_r=GeneratePixelNoise(profile_seed,x+state_.read_pixels_x,y+state_.read_pixels_y,0);pixel[0]=ClampToUint8(pixel[0]+noise_r);// R通道// G通道也可以注入极微小偏移}}}returnerr;}关键难点:GPU 进程的配置同步
之前我们在 Blink(渲染进程)修改参数时,配置是通过渲染进程的命令行参数注入的。但 GPU 进程是独立的进程,无法直接读取渲染进程的内存!
解法:在 Browser 进程启动 GPU 进程时,同样将指纹配置(包括噪声种子)通过--fingerprint-params传递给 GPU 进程,确保渲染进程和 GPU 进程使用相同的种子计算噪声,保证最终哈希的绝对一致。
五、 极致反侦察:屏蔽风控的“探测仪”
除了伪造特征,最高级的对抗是让风控的探测代码无法运行。风控通常会尝试获取WEBGL_debug_renderer_info扩展,以此来调用UNMASKED_VENDOR。
如果你直接在getSupportedExtensions中删除了这个扩展,风控就知道你在刻意隐藏。因为 99% 的真实浏览器都支持这个扩展。
最高明的做法:幽灵扩展
在WebGLRenderingContextBase::GetExtension中拦截:
ScriptValueWebGLRenderingContextBase::getExtension(ScriptState*script_state,constString&name){if(name=="WEBGL_debug_renderer_info"){// 如果风控尝试获取这个扩展,我们不返回 null(那代表不支持)// 而是返回一个“虚假”的扩展对象,这个对象也由我们控制returnScriptValue::From(script_state,CreatePhantomDebugRendererInfo());}// ...}当风控拿到这个虚假对象并调用其属性时,我们依然返回伪造的 Vendor 和 Renderer。风控以为它看到了真相,其实它看到的是我们精心编造的剧本。
六、 避坑实录:WebGL 伪造的死亡陷阱
1. 上下文丢失
WebGL 渲染极度消耗资源,尤其是反复执行复杂的风控检测脚本。如果你的噪声注入算法过重,或者阻断了某些必须的 GPU 命令,极易触发 GPU 进程的保护机制,导致WebGL context lost。如果风控检测到频繁的上下文丢失,会直接判定为恶意环境。
对策:噪声算法必须极致轻量,只做位运算,坚决避免内存分配和复杂计算。
2. 着色器编译错误
风控有时会上传特殊的 GLSL 着色器代码来测试 GPU 的编译行为。不同厂商的 GLSL 编译器对语法宽容度不同。如果你声称自己是 Apple GPU,但编译通过了包含 NVIDIA 专有语法的着色器,就会露馅。
对策:除非你重写了 ANGLE 的着色器翻译器,否则极难防御。目前业内的折中方案是:只提供主流且兼容性好的显卡模板(如 GTX 1060、UHD 630),避免使用小众或架构特殊的型号。
3. 离屏渲染的盲区
风控可能使用离屏 Canvas(OffscreenCanvas)在 Worker 线程中执行 WebGL 检测。Worker 线程中的逻辑往往更难被 Hook。
对策:由于我们修改的是底层的gles2_cmd_decoder和 Blink 内核核心类,这些底层组件是所有 Canvas 类型共享的,因此离屏 Canvas 也会自动走我们的伪造逻辑。
七、 结语
WebGL 的伪装,是一场在操作系统 API、GPU 硬件与浏览器多进程架构之间的走钢丝。
它要求你不仅懂得 JS 层的 API 调用,更必须深入 ANGLE 的翻译机制、Mojo 的通信时序和 GPU Command Buffer 的调度逻辑。
当你能够在一个声称拥有 “NVIDIA RTX 3060” 的浏览器环境中,让 Skia 和 ANGLE 配合默契,既画出符合该型号特征的 3D 像素,又在底层参数查询中滴水不漏时,你的指纹浏览器才算真正拥有了对抗顶级风控的硬实力。