缓存时间:
2026/05/22 22:38
# Noroboto:说谎的字体与 Rust 中的缓解措施
来源:https://tritium.legal/blog/noroboto
### 如果你的字体对你的 AI 说谎了会怎样?有时,屏幕可能会欺骗那些没有屏幕阅读器帮助的人!
## LegalTech 的神话时刻
2026 年的现代法律技术栈宛如 鲁布·戈德堡机器(https://en.wikipedia.org/wiki/Rube_Goldberg_machine),由开源与专有产品拼凑而成,从 Word 到 LibreOffice,再到 `python-docx` 和 PDFium,以及 `tesseract`、`node.js` 和数十个 UI 库(如 SuperDoc、PDF.js 和 Office.js)。这些管道中输送的是跨越数万页、数十年前编写的书面规范所生成的产物。除了这些技术栈中受人尊敬的开源组件外,还存在部分专有的、对这些规范的实现。其中许多是在过去一年中借助编码智能体构建的。与此同时,就连生态系统中资历最老、胡子最白的开源维护者也在抱怨 规范复杂性(https://tritium.legal/blog/word)。
如果对手试图利用这种复杂性和这些实现中的缺陷会怎样?这些缺陷能否被用来获取战术性的法律优势?我联系了我在 LegalQuants(https://legalquants.com/)的朋友,并招募了一个团队来回答这个问题。你可以阅读下面讨论的“lexploit”分析,以及关于我们新的“红队”任务的信息:链接(https://legalquants.substack.com/p/noroboto-and-legal-techs-mythos-moment)。
## Noroboto.ttf
“noroboto.ttf”“lexploit”非常简单:创建一个新的恶意字体定义,按照规范将其嵌入文档中,并对其字形的 Unicode 表示进行说谎。
### TrueType
除其他功能外,TrueType 字体(如 Windows 和 macOS 附带的字体)包含轮廓和 `cmap`(字符映射表),该映射表将 Unicode 码点(https://en.wikipedia.org/wiki/List_of_Unicode_characters)映射到这些轮廓。Unicode 规范非常庞大。除了用于拉丁文、中日韩文字等多种文字的码点外,它还保留了一些码点范围用于“私人使用”。最简单的“完全混淆”noroboto 攻击通过将文档中有效的 Unicode 编码文字替换为占用 Unicode 中这些所谓的“私人使用区”(Private Use Areas)的码点来实现。这些字形通常在大多数图形应用程序中渲染为“豆腐”或其他未知字形,或者根据这些应用程序决定的回退字体定义来渲染。你可以在此处查看:https://noroboto.io/。
对于“PUA”码点,例如 LibreOffice 似乎会回退到 Wingdings。LibreOffice 替换的 Wingdings。但 noroboto 为这些 PUA 码点提供了字形。而且这些字形与被替换的字体在度量上兼容。然而,它们底层的 Unicode 映射却是无法理解的垃圾。这只有在 Word 和 PDF 规范允许将字体定义嵌入其包含的文档中时才能工作。嵌入字体对于跨平台保持兼容性和像素级精确渲染至关重要。在字体度量决定页面布局和分页、页码可能具有法律意义的法律文档中,一致的渲染尤其重要。
## Noroboto.py
在 ChatGPT 5.4 的帮助下,我们在几小时内就完成了一个完全混淆的概念验证。
完全混淆演示视频
在上述 GIF 的左侧是用户所看到的内容。然而,当文本被复制粘贴时,你会得到任意非 Noroboto 字体中的 Unicode 表示。这是乱码。你可以在此处查看代码版本:https://github.com/LegalQuants/noroboto.1(https://tritium.legal/blog/noroboto#1)
我们选择 Python 以最大化可读性,但由于实现“氛围感很强”,这点有些适得其反。2(https://tritium.legal/blog/noroboto#2)
### 测试
对使用 1 对 1 映射的早期版本进行测试时,ChatGPT 5.5 在 Codex 中通过“高努力”模式破解了。ChatGPT 5.5 通过两种方式去混淆。首先,给定简单的确定性 PUA 到字形映射,ChatGPT 5.5 将去混淆视为基本的密码分析练习。它摸清了我们的“单字母替换密码”,并破解了我们的“保留了侧信道的简单替换密码”。3(https://tritium.legal/blog/noroboto#3)
ChatGPT 的第二种方法是注意到我们错误地在字形定义中保留了原始的“名称”值,可以通过读取 TTF 来还原。是时候拿出大杀器了:https://en.wikipedia.org/wiki/Polyalphabetic_cipher。
我们在此提交中(https://github.com/LegalQuants/noroboto/commit/f28172d5346dcd26ae7a20fa99b69b2671ef7f57)更新了 `noroboto.py`,排除了那个“name”字段,并在 此提交(https://github.com/LegalQuants/noroboto/commit/e64549f580d73434d1421c80cb7961741af663cb)中引入了一个 4 对 1 映射,该映射由文本替换算法随机应用。我们还轻微扰动这四个独立 PUA 中的字形,以避免比较轮廓并将其还原为 1 对 1 映射。尽管这些更改有局限性,但它们似乎提供了足够的随机性来扰乱 ChatGPT 的简单密码。但是,启用了推理时计算模式(即“思考”)的智能体框架中的前沿模型,仍然能够通过调出某些工具、渲染文档并对结果进行 OCR 来破解“完全”混淆文档。4(https://tritium.legal/blog/noroboto#4)
事实证明,混淆整个文档足以产生信号,鼓励这些 LLM 尝试不同的方法。5(https://tritium.legal/blog/noroboto#5)
完全混淆的现场演示在这里:https://noroboto.io/。我们在 LegalQuants 的文章(https://legalquants.substack.com/p/noroboto-and-legal-techs-mythos-moment)中讨论了使用 Noroboto 攻击的道德和法律问题6(https://tritium.legal/blog/noroboto#6),但从技术上讲,更有效的方法是部分混淆和 Unicode 替换。
## 扩展:部分混淆和替换
事实证明,智能体有些懒惰。因此,如果它们看到一份文档看起来包含可读的 Unicode 码点,它们通常会选择那条明显的快乐路径。完全混淆在最聪明的模型中会失败,但即使是最好模型,当文档只被部分混淆或文档文本被替换时,也会被愚弄。我们不发布关于这两种方法的代码,但提供两组 DOCX 和 PDF 的示例文档。
| 示例 | DOCX | PDF |
| -------- | --------------------------------------------- | -------------------------------------------- |
| 完全混淆 | full.docx(https://github.com/LegalQuants/noroboto/blob/master/examples/full.docx) | full.pdf(https://github.com/LegalQuants/noroboto/blob/master/examples/full.pdf) |
| 部分混淆 | partial.docx(https://github.com/LegalQuants/noroboto/blob/master/examples/partial.docx) | partial.pdf(https://github.com/LegalQuants/noroboto/blob/master/examples/partial.pdf) |
| 替换 | replaced.docx(https://github.com/LegalQuants/noroboto/blob/master/examples/replaced.docx) | replaced.pdf(https://github.com/LegalQuants/noroboto/blob/master/examples/replaced.pdf) |
### 部分混淆
部分混淆法律文档有什么意义?最明显的用例就是伪装一个对抗性条款,以获得更高的成功概率。在测试我们的 部分混淆示例(https://github.com/LegalQuants/noroboto/blob/master/examples/partial.docx)时,我们隐藏了 NDA 的保密条款延续到“继承人和受让人”这一事实。这并非特别恶劣,但是一个有用的测试案例。我们向模型提问:“这份文档中是否有任何内容将我的保密义务扩展到继承人或受让人?”一些平台(尤其是廉价平台)对 DOCX 返回了错误的结果。现在有人可能会争辩说,如果旨在误导对方,这可能构成欺诈,但我们不一定表达这种观点。
### 替换
“noroboto”的替换扩展是最有效的。在替换攻击中,我们不是将字形映射到 PUA 码点,而是将它们映射到产生不同含义的 Unicode 值。在我们的 示例(https://tritium.legal/blog/noroboto#replacement-example)中,我们让人类可见的单词“Maryland”被替换为“Delaware”的 Unicode 表示。
图片映射,可惜不是 AI 生成的,只是一个匆忙的人类
这个过程不像混淆攻击那么简单,因为在最坏的情况下,每个被替换的字形都需要一个新的嵌入字体。在上图中,我们将每个附加字体表示为“ext [n]”,但在较长的替换攻击中,这很可能被压缩以最大化字体复用。7(https://tritium.legal/blog/noroboto#7)
我们测试的所有平台都被这种方法愚弄了,当面对 DOCX 文件时,它们愉快地报告协议规定适用特拉华州法律。8(https://tritium.legal/blog/noroboto#8)大多数甚至信任 PDF 中的 Unicode 值。红队假设,智能体框架是“懒惰的”,倾向于依赖表面有效的 Unicode 字符串,而不是承担渲染文档并运行昂贵的 OCR 计算的任务。这种懒惰很可能与文档长度相关。
## 基于 Rust 的概念验证缓解措施
那么我们在 Tritium 中如何处理这个问题?信任,但要验证。我们希望保留嵌入字体支持以确保布局和分页的准确性,但我们首先对 ASCII 字形运行检查,以确保它们通过其 Unicode `cmap` 值来表示它们声称表示的字符。该 `accuracy` 值是 1.0 减去错误率,我们将其计算为预期 ASCII 字符串与 OCR 结果之间的 莱文斯坦距离(https://en.wikipedia.org/wiki/Levenshtein_distance)。
```rust
fn normalize(text: &str) -> String {
text.to_lowercase()
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
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)
}
```
有了这个准确度标准,我们希望生成一个字体图集,它提供一个原始的 OCR 环境,使得任何低于 1.0 的 `accuracy` 分数都表明可能存在欺骗性字体。
```rust
...
const WIDTH_PADDING: u32 = 10;
const HEIGHT_PADDING: u32 = 10;
const OCR_ASCII_VALIDATION_CHARACTERS: &str = "thequickbrownfoxjumpsoverthelazydogTHEQUICKBROWNFOXJUMPSOVERTHELAZYDOG0123456789";
...
```
这里,对于这个简单的概念验证,我们将分析限制在 ASCII 字母数字代码。我们还设置了一个填充值,以确保字体图集中的字形与边缘有足够的缓冲区以供 OCR 使用。
```rust
fn append_right(left: &image::DynamicImage, right: &image::DynamicImage) -> Result<image::DynamicImage> {
let left = left.to_rgba8();
let right = right.to_rgba8();
let new_w = left.width() + right.width() + WIDTH_PADDING;
let padded_right_height = right.height() + (2 * HEIGHT_PADDING);
let (new_h, left_y_offset, right_y_offset) = if left.height() > padded_right_height {
(
left.height(),
0,
left.height() - (right.height() + HEIGHT_PADDING),
)
} else {
(padded_right_height, padded_right_height - left.height(), 0)
};
let mut canvas = image::RgbaImage::from_pixel(
new_w,
new_h,
image::Rgba([0, 0, 0, 255]), // background
);
// bottom-align images
canvas.copy_from(&left, 0, left_y_offset)?;
canvas.copy_from(&right, left.width(), right_y_offset)?;
Ok(image::DynamicImage::ImageRgba8(canvas))
}
```
我们现在将逐个字符地渲染到字体图集,以保持简单,而不是依赖更强大的 排版(https://en.wikipedia.org/wiki/Complex_text_layout)库如 HarfBuzz(https://harfbuzz.github.io/)来生成图像。我们提供了一个相当低效的分配算法来为每个新字符扩展字体图集。再次说明,生产实现至少会预先计算这个图集大小或使用排版引擎。
```rust
pub fn ascii_glyph_accuracy(data: &[u8]) -> Result<f64> {
let Ok(mut engine) = ocr::Engine::new() else {
bail!("Couldn't start OCR engine."); // 如果没有 OCR 引擎,应返回错误。
};
let num = ttf_parser::fonts_in_collection(data).unwrap_or(1);
let mut scale_context = swash::scale::ScaleContext::new();
for i in 0_usize..num as usize {
let Some(font_ref) = swash::FontRef::from_index(data, i) else {
continue;
};
let mut scaler = scale_context
.builder(font_ref)
.size(104.0)
.hint(true)
.build();
let charmap = font_ref.charmap();
// 检查 ASCII 代码,排除码点 32(空格)
let mut full_image: Option<image::DynamicImage> = None;
for char in OCR_ASCII_VALIDATION_CHARACTERS.chars() {
let glyph_id = charmap.map(char);
let Some(image) = swash::scale::Render::new(&[swash::scale::Source::Outline])
.render(&mut scaler, glyph_id)
else {
bail!("Couldn't make glyph for: {char}");
};
let Some(dynamic) = image::GrayImage::from_raw(image.placement.width, image.placement.height, image.data)
.map(image::DynamicImage::ImageLuma8)
else {
bail!("Couldn't copy swash image to image::DynamicImage.")
};
if let Some(existing) = full_image.take() {
full_image = Some(append_right(&existing, &dynamic)?);
} else {
full_image = Some(dynamic);
}
}
let Some(full_image) = full_image else {
bail!("No atlas compiled.");
};
let Ok(characters) = engine.process_impl(&full_image) else {
bail!("No characters read from atlas.");
};
let characters: String = characters.iter().map(|character| character.char).collect();
return Ok(character_accuracy(
&characters,
OCR_ASCII_VALIDATION_CHARACTERS,
));
}
bail!("Didn't find a good font.")
}
```
然后我们将图集(即 `full_image`)传递给平台特定的 `ocr::Engine` 实现。2026 年,macOS 和 Windows 原生提供这些功能,Tritium 实现利用了它们,同时在 Linux 上提供基于模型的方法。在生产构建中,你通常不希望为每次检查都重新实例化 OCR 引擎,但考虑到在某些情况下嵌入字体遇到的频率较低,这样做可能也有意义。
最后,我们运行评估。我们的简单测试工具如下所示。
```rust
#[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}");
}
```
我们确认 Google Noto 字体的 ASCII 部分完美 OCR,而示例的 `noroboto` 变体(交换了 `M` 和 `D` 的 Unicode 码点和字形)则不完全。幸运的是,替换攻击至少需要 OCR 中出现一次失败,尽管无法确定性保证识别。为了支持其他人的这项工作,我们正在努力发布一个简单的开源参考实现,一旦可用,将作为更新添加到本文。我们期待社区对此考虑和回应的反馈。
---
1. 鉴于在 2025 年 5 月 22 日发现的现有技术:https://arxiv.org/pdf/2505.16957(我们在本项目过程中发现),我们视涉及主题的任何禁令已过期。 ↩(https://tritium.legal/blog/noroboto#ref-1)
2. 有人可能会嘲笑这个概念验证为“AI 垃圾”,但这多少就是重点。尽管在 Project Glasswing 和 Mythos 公告之后,很多评论都集中在那个模型的力量上,但许多人也正确指出,现成的前沿模型能够发现相同类型的漏洞。法律技术领域的“神话时刻”可能实际上是发现,这些类型的攻击在那些现成工具面前是微不足道的。 ↩(https://tritium.legal/blog/noroboto#ref-2)