Noroboto:在Rust中欺骗字体及其缓解措施

Lobsters Hottest 新闻

摘要

一个研究团队展示了一种名为Noroboto的'lexploit',它利用恶意嵌入的TrueType字体来混淆法律文档中的文本,利用文档规范实现的复杂性,可能欺骗AI和人类读者。

<p><a href="https://lobste.rs/s/xsnzi6/noroboto_lying_fonts_mitigation_rust">评论</a></p>
查看原文
查看缓存全文

缓存时间: 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)

相似文章

当非正式文本导致自然语言推理失效:分词失败、分布偏移及针对性缓解策略

arXiv cs.CL

# 分词失败、分布偏移及针对性缓解策略 来源:[https://arxiv.org/html/2604.16787](https://arxiv.org/html/2604.16787) ## 当非正式文本导致自然语言推理失效:分词失败、分布偏移及针对性缓解策略 ###### 摘要 我们研究了在将四种转换操作应用于 SNLI 和 MultiNLI 时,非正式表层形式如何降低 ELECTRA-small(14M)和 RoBERTa-large(355M)的自然语言推理准确率:俚语替换、表情符号替换、Gen-Z 填充词,以及它们的

逆境中的优秀字体

Hacker News Top

一篇探讨意外字体现象与工艺的文章,包括显示屏上的卷帘快门效应以及对字体局限性的创造性应用。

Mistletoe:针对推测解码的隐蔽加速崩溃攻击

arXiv cs.CL

本文识别了基于模型的推测解码在大语言模型中的新漏洞:微小扰动可以在不影响输出质量的情况下降低草稿令牌接受率,从而使加速效果崩溃。作者提出了Mistletoe攻击,该攻击联合优化退化与语义保持,展示了在各种系统上显著的加速降低效果。