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

发布时间:2026/6/11 1:26:51
字体与排版防线: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 执行如下逻辑constspandocument.createElement(span);span.style.fontFamilyArial;// 指定一个常见字体span.innerTextmmmmmmmmmmlli;// 特定字符组合对字形微差异极度敏感document.body.appendChild(span);constrectspan.getBoundingClientRect();// 收集 rect.width, rect.height 甚至小数点后位数constfingerprinthash(rect.widthxrect.height);原理即使两台机器都安装了 Arial由于操作系统底层的 CoreText (Mac) 或 DirectWrite (Win) 渲染引擎的物理差异计算出的字形包围盒尺寸在极小数点后如 123.45678 vs 123.45679存在差异。劣质指纹浏览器的死法用 JS Hook 拦截getBoundingClientRect返回假数据。结果风控用这个伪造的尺寸去定位页面上的按钮发现根本点不中判定环境异常或者将元素设为display:noneHook 依然返回非零尺寸逻辑崩溃。二、 核心认知Chromium 排版引擎的运转真相要伪造排版结果必须理解文字是如何被画到屏幕上的。DOM 与 CSSOMJS 设置了font-family: Arial。Blink Layout (排版)Blink 的排版引擎目前是LayoutNG需要知道每个字符的宽度和高度以便计算换行和容器大小。Font Cache 查询LayoutNG 向 Font Cache 请求 Arial 字体的字形数据。字体回退如果找不到 ArialFont Cache 会根据操作系统的规则寻找替代字体如 Mac 回退到 HelveticaWin 回退到 Sans Serif。底层 API 调用Font Cache 最终调用操作系统的 APISkia 封装了 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.ccFontCache 是 Blink 中负责字体查找的核心单例。找到GetFontData或类似的方法。scoped_refptrSimpleFontDataFontCache::GetFontData(constFontDescriptionfont_description,constAtomicStringfamily){// 【指纹浏览器拦截点】constautofp_configFingerprintConfig::GetInstance();if(fp_config-IsFontFilterEnabled()){// 获取当前预设系统环境允许的字体白名单constautowhitelistfp_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 boundsplatform_data_.BoundsForGlyph(glyph);// 【指纹浏览器拦截点】if(FingerprintConfig::GetInstance()-IsClientRectsNoiseEnabled()){intprofile_seedFingerprintConfig::GetInstance()-GetFontSeed();// 提取字符的 Unicode 码点作为哈希因子确保同一字符偏移一致intcode_pointstatic_castint(glyph);// 注入微观偏移 (类似 Audio/Canvas 的确定性哈希算法)// 注意宽度偏移通常在 1e-5 到 1e-4 级别肉眼不可见但足以改变哈希floatnoise_xGenerateStableNoise(profile_seed,code_point,0);floatnoise_yGenerateStableNoise(profile_seed,code_point,1);// 微调包围盒的宽高bounds.SetWidth(bounds.Width()noise_x);bounds.SetHeight(bounds.Height()noise_y);}returnbounds;}// 获取字符的水平步进宽度floatSimpleFontData::WidthForGlyph(Glyph glyph)const{floatwidthplatform_data_.WidthForGlyph(glyph);if(FingerprintConfig::GetInstance()-IsClientRectsNoiseEnabled()){intprofile_seedFingerprintConfig::GetInstance()-GetFontSeed();intcode_pointstatic_castint(glyph);floatnoiseGenerateStableNoise(profile_seed,code_point,2);widthnoise;}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 帧特征将是下一阶段最残酷的风控。