视觉伪装(下):WebGL 渲染器与厂商特征的底层伪造与屏蔽

在上一篇文章中,我们深入 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 构建了三维一体的检测模型,任何一维的缺失或矛盾都会触发警报:

  1. 身份维 :显卡的厂商和型号(VENDOR / RENDERER)。这是最基础的硬件身份证明。
  2. 能力维 :支持的扩展列表(EXTENSIONS)和参数上限(MAX_TEXTURE_SIZE 等)。不同型号的显卡能力天差地别。
  3. 行为维 :实际渲染 3D 场景后的像素哈希与着色器计算精度。即"你说你是谁,你能不能画出符合你身份的画"。
    劣质指纹浏览器往往只伪造了第一维,却忽略了第二和第三维,导致死无葬身之地。

二、 身份维斩首:拦截 GLGetString

当 JS 执行 gl.getParameter(gl.RENDERER) 时,数据是如何流经 Chromium 的?

  1. JS 层:调用 WebGL 绑定函数。
  2. Blink 层WebGLRenderingContextBase::getParameter() 将请求通过 Mojo IPC 发送给 GPU 进程。
  3. GPU 进程:调用 ANGLE(Almost Native Graphics Layer Engine),ANGLE 将 OpenGL ES 标准调用翻译成底层操作系统的 API(Windows 的 D3D11,Mac 的 Metal,Linux 的 OpenGL)。
  4. 底层驱动:真正的 GPU 驱动返回字符串(如 "NVIDIA GeForce RTX 4090")。
  5. 原路返回 :字符串经 GPU 进程、Mojo 传回 Blink,最终返回给 JS。
    最愚蠢的做法 :在 JS 层 Hook WebGLRenderingContext.prototype.getParameter。风控可以通过创建隐藏的 iframe 获取原生对象,或者检测函数的 toString() 瞬间识破。
    最优雅的做法:在 Blink 层拦截。因为数据从 GPU 进程传回 Blink 时,已经是纯字符串,我们完全不需要让请求真的去跑一趟 GPU 进程。

精准坐标third_party/blink/renderer/modules/webgl/webgl_rendering_context_base.cc

在这个几千行的大文件中,找到 WebGLRenderingContextBase::getParameter 方法。

cpp 复制代码
ScriptValue WebGLRenderingContextBase::getParameter(ScriptState* script_state, GLenum pname) {
  // ... 前置校验逻辑 ...
  // 【指纹浏览器拦截点】
  const auto& fp_config = FingerprintConfig::GetInstance();
  
  if (pname == GL_RENDERER) {
    if (fp_config->HasOverride("webgl_renderer")) {
      // 直接返回 C++ 字符串,不发送 Mojo 请求到 GPU 进程
      return ScriptValue::From(script_state, fp_config->GetString("webgl_renderer"));
    }
  } else if (pname == GL_VENDOR) {
    if (fp_config->HasOverride("webgl_vendor")) {
      return ScriptValue::From(script_state, fp_config->GetString("webgl_vendor"));
    }
  } else if (pname == GL_UNMASKED_RENDERER_WEBGL) {
      // 必须同时拦截 DEBUG 扩展暴露的未屏蔽渲染器
    if (fp_config->HasOverride("webgl_renderer")) {
      return ScriptValue::From(script_state, fp_config->GetString("webgl_renderer"));
    }
  } else if (pname == GL_UNMASKED_VENDOR_WEBGL) {
    if (fp_config->HasOverride("webgl_vendor")) {
      return ScriptValue::From(script_state, fp_config->GetString("webgl_vendor"));
    }
  }
  // 兜底:走原始逻辑,向 GPU 进程发起真实查询
  return GetParameterHelper(script_state, pname);
}

核心优势

  1. 零延迟:省去了跨进程通信和底层驱动的查询时间,执行时序与真实环境无异(甚至更快,但风控极少通过 Renderer 的查询耗时来反推,因为受系统负载影响太大)。
  2. 绝对隐蔽 :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 支持的所有扩展,并返回一个数组。我们需要在这里加上黑名单/白名单过滤。

cpp 复制代码
Vector<String> WebGLRenderingContextBase::SupportedExtensions() {
  // 原始逻辑:获取底层真实支持的扩展
  Vector<String> original_extensions = ...; 
  const auto& fp_config = FingerprintConfig::GetInstance();
  if (fp_config->HasOverride("webgl_extension_blacklist")) {
    Vector<String> filtered_extensions;
    const auto& blacklist = fp_config->GetStringList("webgl_extension_blacklist");
    
    for (const auto& ext : original_extensions) {
      // 如果扩展不在黑名单中,则保留
      if (!blacklist.Contains(ext)) {
        filtered_extensions.push_back(ext);
      }
    }
    return filtered_extensions;
  }
  return original_extensions;
}

实战策略 :在指纹浏览器的业务后台,根据你预设的 RENDERER 型号,维护一个对应的扩展白名单。如果用户选择了 "NVIDIA GTX 1060",下发配置时就剔除 Mac 独占扩展;如果选择了 "Apple M1",就剔除 D3D 特有扩展。

2. 极限参数的动态降级

风控还会查询 MAX_TEXTURE_SIZEMAX_RENDERBUFFER_SIZE 等极限参数。如果你声称自己是低端集成显卡,但返回的 MAX_TEXTURE_SIZE 却是 16384(高端卡特征),就会露馅。

精准坐标 :同样在 WebGLRenderingContextBase::getParameter 中,针对特定的 pname 进行拦截降级。

cpp 复制代码
case GL_MAX_TEXTURE_SIZE:
  if (fp_config->HasOverride("max_texture_size")) {
    return ScriptValue::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 缓冲区。
    如果你只 Hook toDataURL,风控用 readPixels 校验,瞬间穿帮。
    如果你 Hook readPixels,风控如果调用 drawArrays 后不读取,而是继续在此基础上绘制,你注入的噪声会被放大,导致画面肉眼可见的损坏。

2. 破局点:拦截 GPU 进程的 Command Buffer

Chromium 的渲染进程和 GPU 进程通过 Command Buffer(命令缓冲区)通信。所有的 WebGL 指令最终都被序列化成命令,发往 GPU 进程执行。

实战策略 :在 GPU 进程的 GLES2DecoderImpl 中拦截 DoReadPixels

精准坐标gpu/command_buffer/service/gles2_cmd_decoder.cc

当 GPU 进程收到读取像素的命令时,数据已经从显卡渲染完毕并读入了系统内存。这是注入噪声的最佳时机。

cpp 复制代码
error::Error GLES2DecoderImpl::HandleReadPixels(uint32_t immediate_data_size, const volatile void* cmd_data) {
  // 1. 执行真实的 GPU 像素读取
  error::Error err = DoReadPixels(...);
  
  if (err == error::kNoError && FingerprintConfig::GetInstance()->IsWebGLNoiseEnabled()) {
    int profile_seed = FingerprintConfig::GetInstance()->GetWebGLSeed();
    uint8_t* pixels = static_cast<uint8_t*>(dst_pixels);
    
    // 2. 对读取回的缓冲区注入与 Canvas 2D 类似的物理噪声
    // 注意:必须严格遵守 pack_alignment 对齐规则,否则会导致图像错位
    int pack_alignment = state_.pack_alignment;
    int row_bytes = width * 4;
    int padded_row_bytes = (row_bytes + pack_alignment - 1) & ~(pack_alignment - 1);
    for (int y = 0; y < height; ++y) {
      uint8_t* row = pixels + y * padded_row_bytes;
      for (int x = 0; x < width; ++x) {
        uint8_t* pixel = row + x * 4;
        // 调用前文定义的稳定噪声生成器
        int noise_r = GeneratePixelNoise(profile_seed, x + state_.read_pixels_x, y + state_.read_pixels_y, 0);
        pixel[0] = ClampToUint8(pixel[0] + noise_r); // R通道
        // G通道也可以注入极微小偏移
      }
    }
  }
  return err;
}

关键难点:GPU 进程的配置同步

之前我们在 Blink(渲染进程)修改参数时,配置是通过渲染进程的命令行参数注入的。但 GPU 进程是独立的进程,无法直接读取渲染进程的内存!

解法 :在 Browser 进程启动 GPU 进程时,同样将指纹配置(包括噪声种子)通过 --fingerprint-params 传递给 GPU 进程,确保渲染进程和 GPU 进程使用相同的种子计算噪声,保证最终哈希的绝对一致。

五、 极致反侦察:屏蔽风控的"探测仪"

除了伪造特征,最高级的对抗是让风控的探测代码无法运行 。风控通常会尝试获取 WEBGL_debug_renderer_info 扩展,以此来调用 UNMASKED_VENDOR

如果你直接在 getSupportedExtensions 中删除了这个扩展,风控就知道你在刻意隐藏。因为 99% 的真实浏览器都支持这个扩展。

最高明的做法:幽灵扩展

WebGLRenderingContextBase::GetExtension 中拦截:

cpp 复制代码
ScriptValue WebGLRenderingContextBase::getExtension(ScriptState* script_state, const String& name) {
  if (name == "WEBGL_debug_renderer_info") {
    // 如果风控尝试获取这个扩展,我们不返回 null(那代表不支持)
    // 而是返回一个"虚假"的扩展对象,这个对象也由我们控制
    return ScriptValue::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 像素,又在底层参数查询中滴水不漏时,你的指纹浏览器才算真正拥有了对抗顶级风控的硬实力。

相关推荐
东风破_1 小时前
JS 数据类型:从八种分类到栈与堆的内存真相
javascript
YIAN1 小时前
# 从入门到封装:一文搞懂 Fetch API 所有用法(新手友好)
前端·javascript
xiaofeichaichai2 小时前
Tree Shaking
前端·javascript
niconicoC2 小时前
让 Three.js 场景更真实:我用高斯泼溅和 SparkJS 做了一个可交互的 3D Demo
前端·webgl
Darling噜啦啦2 小时前
JavaScript 数组深度解析:从纯函数到二维数组陷阱,一文吃透前端数据结构核心
前端·javascript·数据结构
万少2 小时前
一封邮件,让我重新打开了搁置半年的鸿蒙应用
前端·javascript·后端
数据知道2 小时前
浏览器硬件参数欺骗:CPU核心数、内存大小、设备像素比的精准伪造
爬虫·数据采集·指纹浏览器·浏览器指纹
To_OC3 小时前
从一段定时器代码,重新捋清 JS 同步、异步与 Promise
前端·javascript·代码规范
拙慕JULY3 小时前
小程序返回 base64 文件报错
开发语言·javascript·小程序