在指纹浏览器的对抗领域,当视觉和听觉的底层伪装已经固若金汤时,很多开发者会折戟于一块看似不起眼的暗礁——字体与排版引擎。
风控系统对字体的检测,绝非仅仅看看你装了什么字体那么简单。它利用的是文档排版后渲染尺寸的物理微差异。同一行文字,在安装了 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 执行如下逻辑:
constspan=document.createElement('span');span.style.fontFamily='Arial';// 指定一个常见字体span.innerText='mmmmmmmmmmlli';// 特定字符组合,对字形微差异极度敏感document.body.appendChild(span);constrect=span.getBoundingClientRect();// 收集 rect.width, rect.height 甚至小数点后位数constfingerprint=hash(rect.width+'x'+rect.height);原理:即使两台机器都安装了 Arial,由于操作系统底层的 CoreText (Mac) 或 DirectWrite (Win) 渲染引擎的物理差异,计算出的字形包围盒尺寸在极小数点后(如 123.45678 vs 123.45679)存在差异。
劣质指纹浏览器的死法:用 JS Hook 拦截getBoundingClientRect,返回假数据。结果风控用这个伪造的尺寸去定位页面上的按钮,发现根本点不中,判定环境异常;或者将元素设为display:none,Hook 依然返回非零尺寸,逻辑崩溃。
二、 核心认知:Chromium 排版引擎的运转真相
要伪造排版结果,必须理解文字是如何被画到屏幕上的。
- DOM 与 CSSOM:JS 设置了
font-family: Arial。 - Blink Layout (排版):Blink 的排版引擎(目前是LayoutNG)需要知道每个字符的宽度和高度,以便计算换行和容器大小。
- Font Cache 查询:LayoutNG 向 Font Cache 请求 Arial 字体的字形数据。
- 字体回退:如果找不到 Arial,Font Cache 会根据操作系统的规则寻找替代字体(如 Mac 回退到 Helvetica,Win 回退到 Sans Serif)。
- 底层 API 调用:Font Cache 最终调用操作系统的 API(Skia 封装了 CoreText/DirectWrite)获取字形的物理包围盒。
- Layout 完成:将计算出的尺寸写回 DOM,供 JS 读取。
关键点:伪造字体指纹,决不能在 JS 层改结果,必须在**第 3 步(Font Cache 查询)和第 5 步(底层 API 返回尺寸时)**动刀。
三、 斩断显式探测:系统字体枚举的底层拦截
首先解决“有没有”的问题。必须让浏览器在底层就认为,自己只拥有特定系统该有的字体。
1. 拦截document.fontsAPI
精准坐标: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或类似的方法。
scoped_refptr<SimpleFontData>FontCache::GetFontData(constFontDescription&font_description,constAtomicString&family){// 【指纹浏览器拦截点】constauto&fp_config=FingerprintConfig::GetInstance();if(fp_config->IsFontFilterEnabled()){// 获取当前预设系统环境允许的字体白名单constauto&whitelist=fp_config->GetAllowedFonts();// 如果请求的字体不在白名单中,直接返回 nullptr,假装没有这个字体if(!whitelist.Contains(family)){returnnullptr;// 返回空,触发排版引擎的 fallback 逻辑}}// 兜底:走真实的系统字体查找逻辑returnGetFontDataInternal(font_description,family);}效果:
当你预设环境为 Mac 时,即使宿主机是 Windows,当风控 JS 尝试渲染“微软雅黑”时,FontCache 直接返回空。Blink 会自动回退到 Mac 环境下标准的 Sans-serif 字体链。
这不仅完美防御了字体枚举,更重要的是,它统一了排版引擎的行为路径,为后续伪造 ClientRects 奠定了基础。
四、 粉碎隐式探测:ClientRects 尺寸的微观伪造
解决了“有没有”的问题,接下来解决“多大”的问题。这也是最难的骨节眼。
由于不同操作系统的字体渲染引擎物理差异,即使同样渲染 Arial 的 ‘m’,Mac 和 Win 的宽度在浮点数级也不同。如果我们强制 Mac 环境回退到了 Helvetica,那么计算出的尺寸必须符合 Mac 的物理特征,而不是当前 Windows 宿主机的特征。
错误思路:HookgetBoundingClientRect
在 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中,获取单个字符宽度和包围盒的方法:
// 获取字符的边界框FloatRectSimpleFontData::BoundsForGlyph(Glyph glyph)const{// 原始逻辑:从底层 OS API 读取真实尺寸FloatRect bounds=platform_data_.BoundsForGlyph(glyph);// 【指纹浏览器拦截点】if(FingerprintConfig::GetInstance()->IsClientRectsNoiseEnabled()){intprofile_seed=FingerprintConfig::GetInstance()->GetFontSeed();// 提取字符的 Unicode 码点作为哈希因子,确保同一字符偏移一致intcode_point=static_cast<int>(glyph);// 注入微观偏移 (类似 Audio/Canvas 的确定性哈希算法)// 注意:宽度偏移通常在 1e-5 到 1e-4 级别,肉眼不可见,但足以改变哈希floatnoise_x=GenerateStableNoise(profile_seed,code_point,0);floatnoise_y=GenerateStableNoise(profile_seed,code_point,1);// 微调包围盒的宽高bounds.SetWidth(bounds.Width()+noise_x);bounds.SetHeight(bounds.Height()+noise_y);}returnbounds;}// 获取字符的水平步进宽度floatSimpleFontData::WidthForGlyph(Glyph glyph)const{floatwidth=platform_data_.WidthForGlyph(glyph);if(FingerprintConfig::GetInstance()->IsClientRectsNoiseEnabled()){intprofile_seed=FingerprintConfig::GetInstance()->GetFontSeed();intcode_point=static_cast<int>(glyph);floatnoise=GenerateStableNoise(profile_seed,code_point,2);width+=noise;}returnwidth;}底层逻辑剖析:
- 排版源头注入:我们在 LayoutNG 计算排版之前,就篡改了字符的度量数据。LayoutNG 会基于这些被篡改的宽度进行换行、对齐计算。
- 全局一致性:因为 DOM 树中所有相同字符的排版都基于同一个被篡改的源头,所以页面布局在逻辑上是自洽的,绝对不会出现错位或点不中按钮的情况。
- 哈希闭环:当风控 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 上文字边缘的像素特征,会发现物理尺寸与像素渲染特征不匹配。
破局:
- 偏移量必须极小:保持在
1e-5级别,这种级别的变化远小于一个物理像素,不会触发亚像素边缘的重绘异常。 - 对齐 OS 特征:如果预设环境是 Mac,即使宿主机是 Win,由于我们在 FontCache 层面强制使用了 Mac 的 fallback 字体,Blink 的渲染管线会自动根据 Mac 的特征选择无亚像素平滑的灰度抗锯齿,物理特征自然对齐。
六、 避坑实录:字体防线上的三大暗礁
1. 致命的零宽字符与回退死循环
如果 HookFontCache::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 帧特征,将是下一阶段最残酷的风控。