攻击原理:当字体开始 "说谎"
TrueType 字体文件的核心结构包含两个关键组件:字形轮廓(glyph outlines)和字符映射表(cmap)。正常情况下,cmap 将 Unicode 码点与对应的字形建立一一映射关系,确保文本处理系统能够正确渲染和解析文档内容。
Noroboto 攻击利用这一机制的灵活性实施欺骗。攻击者可以构造恶意字体文件,使字形显示的内容与 Unicode 表示的内容完全分离。具体而言,攻击者将合法字符的字形映射到 Unicode 私有使用区(Private Use Areas, PUA)的码点,或者更危险地 —— 将 "Maryland" 的字形映射到 "Delaware" 的 Unicode 码点。
这种分离产生了一个危险的认知鸿沟:人类审查者看到的是渲染后的字形内容,而 LLM、文本提取工具和搜索引擎索引的是 Unicode 层面的文本表示。在法律文档场景中,这可能导致管辖权条款的实质性篡改;在金融场景中,合同金额的字面显示与机器解析值可能出现数量级差异。
三种攻击变体的技术特征
完全混淆(Full Obfuscation) 将所有可见字符映射到 PUA 码点。在 LibreOffice 等应用中,PUA 码点通常会回退到 Wingdings 等符号字体显示,但嵌入的恶意字体提供了对应的字形定义。这种攻击在视觉上呈现为正常文本,但复制粘贴或文本提取时得到的是无意义的乱码。
部分混淆(Partial Obfuscation) 针对文档中的关键条款实施定点欺骗。例如将保密条款中的 "successors and assigns" 部分进行混淆,而保持文档其余部分的可读性。这种策略利用了 LLM 的 "惰性" 特征 —— 当文档大部分内容看似正常时,模型倾向于采信表面合法的 Unicode 表示。
替换攻击(Replacement) 是最隐蔽且危险的变体。攻击者不依赖 PUA 码点,而是直接将字形映射到具有实际语义的 Unicode 值。例如将显示为 "$2,000,000" 的字形映射到代表 "$1,000,000" 的 Unicode 序列。这种攻击能够绕过基于字符集白名单的防御,因为提取的文本本身是完全合法的 Unicode 字符串。
Rust 缓解实现:信任但验证
针对嵌入字体的文档处理,"信任但验证"(Trust, but verify)是核心防御原则。Tritium 项目提出的 Rust 实现方案通过 OCR 验证建立字形与 Unicode 的一致性检查机制。
核心验证逻辑
缓解实现的核心是计算字符准确率(character accuracy):
fn character_accuracy(expected: &str, actual: &str) -> f64 {
let expected = normalize(expected);
let actual = normalize(actual);
let distance = strsim::levenshtein(&expected, &actual);
let expected_len = expected.chars().count().max(1);
1.0_f64 - (distance as f64 / expected_len as f64)
}
该函数使用 Levenshtein 距离衡量预期字符串与实际 OCR 结果的差异程度,返回 1.0 表示完全匹配,低于 1.0 表示存在差异。
字体图谱生成与验证
验证流程首先需要构建字体图谱(font atlas):
const OCR_ASCII_VALIDATION_CHARACTERS: &str =
"thequickbrownfoxjumpsoverthelazydogTHEQUICKBROWNFOXJUMPSOVERTHELAZYDOG0123456789";
const WIDTH_PADDING: u32 = 10;
const HEIGHT_PADDING: u32 = 10;
验证字符集覆盖 ASCII 字母大小写和数字,提供充分的字形样本。边距参数确保 OCR 引擎能够正确识别边缘字符。
使用 swash 库进行字体渲染:
let mut scale_context = swash::scale::ScaleContext::new();
let mut scaler = scale_context
.builder(font_ref)
.size(104.0)
.hint(true)
.build();
渲染字号设置为 104.0,开启 hinting 以获得清晰的位图输出。对于每个验证字符,提取其字形 ID 并渲染为灰度图像,然后拼接成完整的字体图谱。
OCR 验证与阈值判定
字体图谱通过平台原生 OCR 引擎或模型推理进行处理。macOS 和 Windows 提供系统级 OCR 能力,Linux 环境可回退到模型方案。
let Ok(characters) = engine.process_impl(&full_image) else {
bail!("No characters read from atlas.");
};
let characters: String = characters.iter()
.map(|c| c.char)
.collect();
return Ok(character_accuracy(&characters, OCR_ASCII_VALIDATIONS));
验证逻辑通过单元测试体现:
#[test]
fn noto_font_has_ascii() {
let data = include_bytes!("fonts/noto.ttf");
let accuracy = ascii_glyph_accuracy(data).expect("Glyphs should OCR.");
assert!((accuracy == 1.0));
}
#[test]
fn notoroboto_font_has_bad_ascii() {
let data = include_bytes!("fonts/noroboto.ttf");
let accuracy = ascii_glyph_accuracy(data).expect("Glyphs should OCR.");
assert!((accuracy < 1.0), "got: {accuracy}");
}
合法字体应达到 1.0 的准确率,而恶意字体由于字形与 Unicode 的不一致,OCR 结果将偏离预期值。
工程实践参数与清单
基于上述实现,以下是可落地的工程参数:
验证字符集:使用 62 个 ASCII 字符(a-z, A-Z, 0-9)作为最小验证集。完整验证可扩展至标点符号和常见 Unicode 区块。
准确率阈值:
- 1.0:完全可信,无欺骗迹象
- 0.95-0.99:轻微差异,可能为字体渲染变体,建议人工复核
- < 0.95:高度可疑,应拒绝处理或触发告警
渲染参数:
- 字号:104px(平衡 OCR 精度与性能)
- Hinting:启用(确保小字号清晰度)
- 边距:10px 水平,10px 垂直(防止边缘截断)
性能优化:
- 预计算字体图谱尺寸,避免动态扩容
- 生产环境复用 OCR 引擎实例
- 对非嵌入字体文档跳过验证流程
集成检查清单:
- 文档解析阶段识别嵌入字体
- 提取字体二进制数据
- 生成 ASCII 验证图谱
- 执行 OCR 识别
- 计算 Levenshtein 准确率
- 根据阈值决定处理策略(通过 / 警告 / 拒绝)
局限与扩展方向
当前实现主要覆盖 ASCII 字符集的验证。对于 CJK 等复杂文字系统,需要扩展验证字符集并考虑竖排布局等排版特性。替换攻击的检测依赖于 OCR 引擎对混淆字形的识别能力,攻击者可能通过微调字形轮廓绕过检测,这要求持续更新 OCR 模型或引入多引擎交叉验证。
字体渲染的跨平台一致性也是一个挑战。不同操作系统和版本的字体渲染引擎可能产生细微差异,这需要在准确率阈值设定时预留合理的容差空间。
资料来源
- Tritium Legal: "Noroboto: Lying Fonts and Mitigation in Rust" (2025)
- Fifield et al.: "Fingerprinting Web Users Through Font Metrics" (USENIX Security 2015)
- LegalQuants GitHub: github.com/LegalQuants/noroboto
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。