字体与排版防线:ClientRects 与系统字体枚举的底层拦截与伪造

在指纹浏览器的对抗领域,当视觉和听觉的底层伪装已经固若金汤时,很多开发者会折戟于一块看似不起眼的暗礁------字体与排版引擎

风控系统对字体的检测,绝非仅仅看看你装了什么字体那么简单。它利用的是文档排版后渲染尺寸的物理微差异 。同一行文字,在安装了 Arial 的 Windows 上和在 macOS 上,由于底层光栅化引擎和字体回退机制的不同,其通过 getBoundingClientRect() 获取的宽度和高度在浮点数级别是截然不同的。

这种基于排版引擎的检测,被称为 ClientRects 指纹。它极度隐蔽,且极难通过 JS Hook 完美伪造,因为任何 JS 层的拦截都会破坏排版逻辑的闭合性,导致页面布局肉眼可见的错乱。

本文将摒弃水话,直接插进 Chromium 的排版引擎核心,拆解系统字体枚举与 ClientRects 的底层计算逻辑,给出基于 C++ 编译级的拦截与伪造方案。

一、 杀机暗藏:字体指纹的双重绞杀

风控对字体的检测通常分为两路,互为印证,一旦矛盾直接击毙:

1. 显式枚举:document.fonts 与 JS 探针

风控 JS 尝试遍历 document.fonts,或者通过向 DOM 插入多个应用了不同字体的 span,然后读取 offsetWidth 来探测特定字体是否存在。

痛点:如果你声称自己是 Mac 系统,但探测出了 Windows 独占的"微软雅黑",瞬间暴露。

2. 隐式排版:ClientRects / TextMetrics

这是更致命的杀招。风控 JS 执行如下逻辑:

javascript 复制代码
const span = document.createElement('span');
span.style.fontFamily = 'Arial'; // 指定一个常见字体
span.innerText = 'mmmmmmmmmmlli'; // 特定字符组合,对字形微差异极度敏感
document.body.appendChild(span);
const rect = span.getBoundingClientRect();
// 收集 rect.width, rect.height 甚至小数点后位数
const fingerprint = hash(rect.width + 'x' + rect.height); 

原理 :即使两台机器都安装了 Arial,由于操作系统底层的 CoreText (Mac) 或 DirectWrite (Win) 渲染引擎的物理差异,计算出的字形包围盒尺寸在极小数点后(如 123.45678 vs 123.45679)存在差异。

劣质指纹浏览器的死法 :用 JS Hook 拦截 getBoundingClientRect,返回假数据。结果风控用这个伪造的尺寸去定位页面上的按钮,发现根本点不中,判定环境异常;或者将元素设为 display:none,Hook 依然返回非零尺寸,逻辑崩溃。

二、 核心认知:Chromium 排版引擎的运转真相

要伪造排版结果,必须理解文字是如何被画到屏幕上的。

  1. DOM 与 CSSOM :JS 设置了 font-family: Arial
  2. Blink Layout (排版) :Blink 的排版引擎(目前是 LayoutNG)需要知道每个字符的宽度和高度,以便计算换行和容器大小。
  3. Font Cache 查询:LayoutNG 向 Font Cache 请求 Arial 字体的字形数据。
  4. 字体回退:如果找不到 Arial,Font Cache 会根据操作系统的规则寻找替代字体(如 Mac 回退到 Helvetica,Win 回退到 Sans Serif)。
  5. 底层 API 调用:Font Cache 最终调用操作系统的 API(Skia 封装了 CoreText/DirectWrite)获取字形的物理包围盒。
  6. Layout 完成 :将计算出的尺寸写回 DOM,供 JS 读取。
    关键点:伪造字体指纹,决不能在 JS 层改结果,必须在**第 3 步(Font Cache 查询)和第 5 步(底层 API 返回尺寸时)**动刀。

三、 斩断显式探测:系统字体枚举的底层拦截

首先解决"有没有"的问题。必须让浏览器在底层就认为,自己只拥有特定系统该有的字体。

1. 拦截 document.fonts API

精准坐标third_party/blink/renderer/modules/cssfontfacedom/

Blink 对 CSS Font Loading API 的实现中,暴露了当前可用字体列表。我们需要在返回列表前进行过滤。

但这种做法治标不治本,风控可以通过创建 DOM 元素测量宽度来绕过 API。

2. 核心破局:拦截 Font Cache 的字体查找逻辑

这是最彻底的物理级隔离。当排版引擎询问"系统有没有某某字体"时,我们强制让它找不到,迫使其走向预设的回退逻辑。

精准坐标third_party/blink/renderer/platform/fonts/font_cache.cc

FontCache 是 Blink 中负责字体查找的核心单例。找到 GetFontData 或类似的方法。

cpp 复制代码
scoped_refptr<SimpleFontData> FontCache::GetFontData(
    const FontDescription& font_description,
    const AtomicString& family) {
  
  // 【指纹浏览器拦截点】
  const auto& fp_config = FingerprintConfig::GetInstance();
  if (fp_config->IsFontFilterEnabled()) {
    // 获取当前预设系统环境允许的字体白名单
    const auto& whitelist = fp_config->GetAllowedFonts();
    
    // 如果请求的字体不在白名单中,直接返回 nullptr,假装没有这个字体
    if (!whitelist.Contains(family)) {
      return nullptr; // 返回空,触发排版引擎的 fallback 逻辑
    }
  }
  // 兜底:走真实的系统字体查找逻辑
  return GetFontDataInternal(font_description, family);
}

效果

当你预设环境为 Mac 时,即使宿主机是 Windows,当风控 JS 尝试渲染"微软雅黑"时,FontCache 直接返回空。Blink 会自动回退到 Mac 环境下标准的 Sans-serif 字体链。

这不仅完美防御了字体枚举,更重要的是,它统一了排版引擎的行为路径,为后续伪造 ClientRects 奠定了基础。

四、 粉碎隐式探测:ClientRects 尺寸的微观伪造

解决了"有没有"的问题,接下来解决"多大"的问题。这也是最难的骨节眼。

由于不同操作系统的字体渲染引擎物理差异,即使同样渲染 Arial 的 'm',Mac 和 Win 的宽度在浮点数级也不同。如果我们强制 Mac 环境回退到了 Helvetica,那么计算出的尺寸必须符合 Mac 的物理特征,而不是当前 Windows 宿主机的特征。

错误思路:Hook getBoundingClientRect

在 JS 层或 V8 绑定层改返回值,会导致严重的布局错乱。因为 Blink 内部的排版计算(LayoutNG)依然使用的是真实尺寸,JS 拿到的尺寸与实际渲染的像素不对齐。

正确思路:在 Layout 之前,注入字形度量偏移

我们必须在 Blink 向操作系统 API 请求字形包围盒的时候,对返回的浮点数进行微调。

精准坐标third_party/blink/renderer/platform/fonts/

Blink 中定义了字形的度量数据结构 GlyphMetrics,以及获取这些数据的接口。真正的底层数据来源于 Skia 对操作系统 API 的调用。

third_party/blink/renderer/platform/fonts/simple_font_data.cc 中,获取单个字符宽度和包围盒的方法:

cpp 复制代码
// 获取字符的边界框
FloatRect SimpleFontData::BoundsForGlyph(Glyph glyph) const {
  // 原始逻辑:从底层 OS API 读取真实尺寸
  FloatRect bounds = platform_data_.BoundsForGlyph(glyph);
  
  // 【指纹浏览器拦截点】
  if (FingerprintConfig::GetInstance()->IsClientRectsNoiseEnabled()) {
    int profile_seed = FingerprintConfig::GetInstance()->GetFontSeed();
    // 提取字符的 Unicode 码点作为哈希因子,确保同一字符偏移一致
    int code_point = static_cast<int>(glyph); 
    
    // 注入微观偏移 (类似 Audio/Canvas 的确定性哈希算法)
    // 注意:宽度偏移通常在 1e-5 到 1e-4 级别,肉眼不可见,但足以改变哈希
    float noise_x = GenerateStableNoise(profile_seed, code_point, 0); 
    float noise_y = GenerateStableNoise(profile_seed, code_point, 1);
    
    // 微调包围盒的宽高
    bounds.SetWidth(bounds.Width() + noise_x);
    bounds.SetHeight(bounds.Height() + noise_y);
  }
  
  return bounds;
}
// 获取字符的水平步进宽度
float SimpleFontData::WidthForGlyph(Glyph glyph) const {
  float width = platform_data_.WidthForGlyph(glyph);
  
  if (FingerprintConfig::GetInstance()->IsClientRectsNoiseEnabled()) {
    int profile_seed = FingerprintConfig::GetInstance()->GetFontSeed();
    int code_point = static_cast<int>(glyph);
    float noise = GenerateStableNoise(profile_seed, code_point, 2);
    width += noise;
  }
  
  return width;
}

底层逻辑剖析

  1. 排版源头注入:我们在 LayoutNG 计算排版之前,就篡改了字符的度量数据。LayoutNG 会基于这些被篡改的宽度进行换行、对齐计算。
  2. 全局一致性 :因为 DOM 树中所有相同字符的排版都基于同一个被篡改的源头,所以页面布局在逻辑上是自洽的,绝对不会出现错位或点不中按钮的情况
  3. 哈希闭环 :当风控 JS 调用 getBoundingClientRect() 时,它读取的是 LayoutNG 计算完毕的值,这个值自然包含了我们注入的微观偏移,且多次读取结果稳定一致。

五、 高阶防御:TextMetrics 与亚像素渲染

风控不仅测 ClientRects,还会测更精确的 TextMetrics(通过 ctx.measureText() 获取),它暴露了更细粒度的基线、上升线等浮点数据。

1. Canvas 2D 的 TextMetrics 伪造

精准坐标third_party/blink/renderer/modules/canvas/canvas2d/

canvas_rendering_context_2d.cc 中拦截 measureText。其底层同样调用 SimpleFontData::WidthForGlyph,因此只要我们在前文所述的 SimpleFontData 层面注入偏移,Canvas 的文本测量也会自动带上噪声,无需额外 Hook。

2. 亚像素渲染的悖论

这是一个极度隐秘的坑。操作系统的字体渲染会使用亚像素抗锯齿(如 ClearType),这会在字形边缘产生彩色过渡像素。如果你的偏移算法盲目改变了字形的物理尺寸,但没有同步改变底层的渲染规则,风控通过提取 Canvas 上文字边缘的像素特征,会发现物理尺寸与像素渲染特征不匹配。

破局

  1. 偏移量必须极小 :保持在 1e-5 级别,这种级别的变化远小于一个物理像素,不会触发亚像素边缘的重绘异常。
  2. 对齐 OS 特征:如果预设环境是 Mac,即使宿主机是 Win,由于我们在 FontCache 层面强制使用了 Mac 的 fallback 字体,Blink 的渲染管线会自动根据 Mac 的特征选择无亚像素平滑的灰度抗锯齿,物理特征自然对齐。

六、 避坑实录:字体防线上的三大暗礁

1. 致命的零宽字符与回退死循环

如果 Hook FontCache::GetFontData 过滤不当,把某些隐藏的系统默认字体也过滤掉了,可能导致 Blink 在寻找回退字体时陷入死循环,最终栈溢出崩溃。

对策 :白名单中必须保留 sans-serif, serif, monospace 等通用字体族,并且在拦截逻辑中,一旦发现请求的是通用字体族,必须无条件放行。

2. 首次渲染的性能雪崩

如果对于每一个字符的 WidthForGlyph 都执行一次哈希计算,在渲染长页面时会导致排版耗时急剧增加,首屏白屏时间过长。

对策 :在 SimpleFontData 内部建立基于 Glyph ID 的 LRU 缓存。相同字符的偏移量只计算一次,后续直接从哈希表读取,将性能损耗降至接近零。

3. Icon Font 的惨案

现代网页大量使用 Icon Font(如 FontAwesome)。如果你的字体白名单过于严格,把 Icon Font 也过滤了,会导致网页上出现大量方块乱码。

对策 :白名单机制必须支持"通配符"或"动态追加"。在初始化时除了预设系统字体,还应允许网页正常加载通过 @font-face 声明的远程网络字体,不能一棍子打死。

七、 结语:多维度物理一致性的终极闭环

字体与排版防线,是风控系统从"粗放式探测"走向"微观级验证"的缩影。

它揭示了一个残酷的真相:在指纹浏览器的对抗中,局部伪造是毫无意义的。你改了 UA 假装是 Mac,如果底层的排版引擎依然算出 Windows 的尺寸,你依然是个靶子。

通过深入 Chromium 的 FontCache 拦截查询,在 SimpleFontData 注入确定性度量偏移,我们终于将排版引擎的物理微差异,牢牢地钉死在了我们设定的时空规则内。

至此,从 Navigator 身份、Canvas/WebGL 视觉、Audio 听觉,再到 Font 排版,浏览器本地的 C++ 内核级伪装已经闭环。但反检测的战争远未结束,当本地环境做到极致后,风控的探照灯必将照向浏览器与外界通信的必经之路------网络层。TLS 指纹(JA3)、HTTP/2 帧特征,将是下一阶段最残酷的风控。

相关推荐
一壶纱1 小时前
一个用于 UniApp 项目的 Pinia 持久化插件
前端·javascript·vue.js
凌涘1 小时前
JS 八大基本类型:一场内存视角的冒险之旅
前端·javascript
数据知道1 小时前
视觉伪装(上):Canvas 指纹生成原理与 Skia 图形库底层注入噪声
开发语言·javascript·ecmascript·数据采集·指纹浏览器
文阿花2 小时前
Echarts实现自定旋转3D饼状图
javascript·3d·echarts·饼状图
捷米特网关模块通讯2 小时前
老旧松下PLC组网难?串口转以太网模块实现ModbusTCP服务器无缝对接
数据采集·以太网模块·工业自动化·智能网关·总线协议·松下plc
meilindehuzi_a2 小时前
深入理解 JavaScript 的同步与异步机制:从单线程设计到 Promise 核心应用
开发语言·javascript·ecmascript
如烟花的信页2 小时前
加速乐cookie逆向分析
javascript·爬虫·python·js逆向
永远的WEB小白2 小时前
css改变svg图标的颜色
前端·javascript·css
ikoala3 小时前
Codex 不得不装的 12 个插件,都在这了
前端·javascript·后端