{"slug": "noroboto-lying-fonts-and-mitigation-in-rust", "title": "Noroboto: Lying Fonts and Mitigation in Rust", "summary": "A team of legal technology researchers at LegalQuants has developed a \"lexploit\" called Noroboto, a malicious TrueType font that lies about the Unicode representation of its glyphs to obfuscate text in legal documents. The attack embeds the font in Word and PDF files, making text appear normal to human readers while producing incomprehensible Unicode garbage when copied and pasted, potentially undermining AI-powered legal document analysis and discovery tools. The researchers warn that the exploit exploits the complexity of decades-old document specifications and their imperfect implementations across modern legal tech stacks.", "body_md": "### What if your font is lying to your AI?\n\n## LegalTech's Mythos Moment\n\nModern legal tech stacks in 2026 are [Rube Goldberg\nmachines](https://en.wikipedia.org/wiki/Rube_Goldberg_machine) of open-source and proprietary products from Word to LibreOffice, to `python-docx`\n\nand PDFium, to `tesseract`\n\n, `node.js`\n\nand dozens of UI libraries like SuperDoc, PDF.js and\nOffice.js. Through those pipelines are pushed artifacts of decades-old written specifications which span tens of\nthousands\nof pages.\n\nIn addition to the venerated OSS parts of these stacks exist partial, proprietary implementations of these specs. Many of these have been spun up in the last year with the assistance of coding agents.\n\nMeanwhile even the oldest, grayest-beard OSS maintainers in the ecosystem complain of [specification complexity](https://tritium.legal/blog/word).\n\nWhat if an adversary were to try to take advantage of this complexity and the imperfections in these implementations? Could these imperfections be leveraged for a tactical legal advantage?\n\nI reached out to my friends at the [LegalQuants](https://legalquants.com) and recruited a team to\nanswer this question, and you can read the analysis of the \"lexploit\" discussed below and about our new \"Red\nTeam\" mission\nhere: [link](https://legalquants.substack.com/p/noroboto-and-legal-techs-mythos-moment).\n\n## Noroboto.ttf\n\nThe \"noroboto.ttf\" \"lexploit\" is straightforward: create a new malicious font definition which is embedded in a document according to the specification and lies about the Unicode representation of its glyphs.\n\n### TrueType\n\nAmong many other things, TrueType fonts like those distributed with Windows and macOS contain outlines and a\n`cmap`\n\n(or character map)\nwhich maps [Unicode code points](https://en.wikipedia.org/wiki/List_of_Unicode_characters) to these\noutlines.\n\nThe Unicode specification which is huge.\n\nIn addition to code points for scripts such as Latin and CJK, among many others, it also reserves ranges of code points for \"private use\".\n\nThe simplest \"full obfuscation\" noroboto attack works by swapping valid Unicode-encoded scripts in the subject document with Unicode code points occupying these so-called \"Private Use Areas\" of Unicode.\n\nThese glyphs typically render as \"tofu\" or some other unknown glyph in most graphical applications, or as a glyph from a fallback font definition as determined by such applications.\n\nYou can check that out [here](https://noroboto.io).\n\nFor \"PUA\" code points LibreOffice, for example, seems to fallback to Wingdings.\n\nBut noroboto provides a glyph for these PUA code points. And those glyphs are metric compatible with the replaced font. Their underlying Unicode mapping, however, is incomprehensible garbage.\n\nThis only works because the Word and PDF specifications allow for font definitions to be embedded in their containing documents. Embedding fonts is critical to maintain compatibility and pixel-tight rendering across platforms. And consistent rendering is especially important in legal documents where font metrics determine page layout and pagination, and page numbers can have legal meaning.\n\n## Noroboto.py\n\nWith the help of ChatGPT 5.4 we had a proof-of-concept for full obfuscation within a few hours.\n\nOn the left of the above GIF is what the user sees. When the text is copied and pasted, however, you get the\nUnicode representation in an arbitrary non-Noroboto font. It's garbage. You can see a version of the\ncode here: [https://github.com/LegalQuants/noroboto](https://github.com/LegalQuants/noroboto).[1](#1)\n\nWe opted for Python to maximize legibility, but that somewhat backfired given the \"vibes heavy\"\nimplementation.[2](#2)\n\n### Testing\n\nAn early testing against a version which leveraged a 1-to-1 mapping was defeated by ChatGPT 5.5 in Codex using \"high effort\". ChatGPT 5.5 deobfuscated in two ways.\n\nFirst, given the simple PUA-to-glyph deterministic mapping, ChatGPT 5.5 treated deobfuscation as a basic\ncryptoanalysis exercise. It sussed out our \"monoalphabetic\" cipher and broke our \"simple substitution cipher\nwith\nside channels left\nintact\". 3 ChatGPT's second approach was to\nnote that we had erroneously\nleft the original \"name\" value in the glyph\ndefinition which could be reverted by reading the TTF.\n\nTime to pull out the big guns: [https://en.wikipedia.org/wiki/Polyalphabetic_cipher](https://en.wikipedia.org/wiki/Polyalphabetic_cipher).\n\nWe updated `noroboto.py`\n\nin this [commit](https://github.com/LegalQuants/noroboto/commit/f28172d5346dcd26ae7a20fa99b69b2671ef7f57) to\nexclude that \"name\" field and in this [commit](https://github.com/LegalQuants/noroboto/commit/e64549f580d73434d1421c80cb7961741af663cb) to\ninclude a 4-to-1 mapping which is randomly applied by the text replacement algorithm.\n\nWe also perturb the font slightly across the four separate PUAs to avoid comparing the outlines and collapsing them back to a 1-to-1 mapping.\n\nAlthough these changes have limitations, they seemed to supply enough stochasticity to throw off ChatGPT's simple\ncipher. But the frontier models in agentic harnesses with their inference-time computing modes enabled (aka\n\"thinking\") all still manage to crack the \"full\" obfuscation document by shelling out to something, rendering\nthe\ndocument and OCR'ing that result.[4](#4)\n\nIt turns out obfuscating the entire document is enough signal to encourage these LLMs to try different\napproaches.[5](#5)\n\nA live demonstration of full obfuscation is here: [https://noroboto.io](https://noroboto.io).\n\n[We touched on in our\nLegalQuants post](https://legalquants.substack.com/p/noroboto-and-legal-techs-mythos-moment) the ethics and legality of using the Noroboto attack 6, but technically the much more effective approaches are both\npartial obfuscation\nand Unicode replacement.\n\n## Extensions: Partial Obfuscation and Replacement\n\nIt turns out, agents are somewhat lazy.\n\nThus, if they are presented with what appears to be a document containing legible Unicode code points, they often take that apparent happy path.\n\nTotal obfuscation fails this test in the smartest models, but even the best are fooled when a document is only partially obfuscated or the text of the document is replaced.\n\nWe don't release any code on these two approaches but we present two example sets of documents in DOCX and PDF.\n\n| Example | DOCX | |\n|---|---|---|\n| Full obfuscation |\n|\n\n[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)### Partial Obfuscation\n\nWhat's the point of partially obfuscating a legal document?\n\nThe most obvious case is to just disguise an adversarial term with a higher probability of success.\n\nIn testing our [partial obfuscation\nexample](https://github.com/LegalQuants/noroboto/blob/master/examples/partial.docx), we hid the fact that the\nNDA's confidentiality terms carry on to \"successors and assigns\".\n\nThis isn't particularly egregious but was a useful test case.\n\nWe asked the model \"Does anything in this document extend my confidentiality obligations to successors or assigns?\", and some, particularly inexpensive platforms, returned incorrect results for DOCX.\n\nNow some might argue this is fraudulent if it's intended to mislead the other party, but we don't necessarily express that opinion.\n\n### Replacement\n\nThe replacement extension of \"noroboto\" is the most effective.\n\nIn the replacement attack, instead of mapping the glyphs to PUA code points, we map them to Unicode values that create a different meaning.\n\nIn our [example](#replacement-example), we caused the human-visible\nword \"Maryland\" to be replaced\nwith the Unicode representation of \"Delaware\".\n\nThis process isn't as simple as the obfuscation attack because it requires, in the worst case, a new embedded\nfont for each replaced glyph. In the above image, we represent each additional font as \"ext [n]\", but this can\nlikely be compressed in longer replacement attacks to maximize font\nre-use.[7](#7)\n\nAll of the platforms we tested were fooled by this approach and happily reported that the agreement\nprovided for Delaware governing law when presented with a DOCX file. 8. Most even trusted the Unicode values in PDF.\n\nThe Red Team hypothesizes that the agentic harnesses are \"lazy\" and prefer to rely on a facially valid Unicode string rather than undertake to render the document and run an expensive OCR computation. This laziness is likely correlated with the length of the document.\n\n## Proof of Concept Mitigation in Rust\n\nSo how might we handle this in Tritium?\n\nTrust, but verify.\n\nWe want to retain embedded font support to ensure layout and pagination accuracy, but we first run a check\nagainst the ASCII glyphs to ensure they represent the characters they purport to represent via their Unicode\n`cmap`\n\nvalue.\n\nThat `accuracy`\n\nvalue is 1.0 minus the error rate, which we calculate as the [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) between\nthe expected\nASCII string and the OCR result.\n\n``` php\nfn normalize(text: &str) -> String {\n    text.to_lowercase()\n        .split_whitespace()\n        .collect::<Vec<_>>()\n        .join(\" \")\n}\n\nfn character_accuracy(expected: &str, actual: &str) -> f64 {\n    let expected = normalize(expected);\n    let actual = normalize(actual);\n\n    let distance = strsim::levenshtein(&expected, &actual);\n    let expected_len = expected.chars().count().max(1);\n\n    1.0_f64 - (distance as f64 / expected_len as f64)\n}\n```\n\nWith this accuracy criterion, we want to generate a font\natlas which provides a\npristine OCR environment such that anything other than a 1.0 `accuracy`\n\nscore indicates a potentially\ndeceptive font.\n\n``` js\n...\n\nconst WIDTH_PADDING: u32 = 10;\nconst HEIGHT_PADDING: u32 = 10;\n\nconst OCR_ASCII_VALIDATION_CHARACTERS: &str =\n    \"thequickbrownfoxjumpsoverthelazydogTHEQUICKBROWNFOXJUMPSOVERTHELAZYDOG0123456789\";\n\n...\n```\n\nHere we limit our analysis to ASCII alphanumeric codes for this simple proof of concept.\n\nWe also set a padding value to ensure the glyphs in the font atlas have a sufficient buffer from the edge for OCR.\n\n``` php\nfn append_right(left: &image::DynamicImage, right: &image::DynamicImage) -> Result<image::DynamicImage> {\n    let left = left.to_rgba8();\n    let right = right.to_rgba8();\n\n    let new_w = left.width() + right.width() + WIDTH_PADDING;\n\n    let padded_right_height = right.height() + (2 * HEIGHT_PADDING);\n    let (new_h, left_y_offset, right_y_offset) = if left.height() > padded_right_height {\n        (\n            left.height(),\n            0,\n            left.height() - (right.height() + HEIGHT_PADDING),\n        )\n    } else {\n        (padded_right_height, padded_right_height - left.height(), 0)\n    };\n\n    let mut canvas = image::RgbaImage::from_pixel(\n        new_w,\n        new_h,\n        image::Rgba([0, 0, 0, 255]), // background\n    );\n\n    // bottom-align images\n    canvas.copy_from(&left, 0, left_y_offset)?;\n    canvas.copy_from(&right, left.width(), right_y_offset)?;\n\n    Ok(image::DynamicImage::ImageRgba8(canvas))\n}\n```\n\nWe'll now go character-by-character and render to the font atlas to keep it simple, rather than relying on\na more robust [shaping](https://en.wikipedia.org/wiki/Complex_text_layout) library like [HarfBuzz](https://harfbuzz.github.io/) to generate the image.\n\nWe provide a rather inefficient allocation algorithm to extend the font atlas for each new character.\n\nAgain, a production implementation will at a minimum pre-calculate this atlas size or use a shaping engine.\n\n``` php\npub fn ascii_glyph_accuracy(data: &[u8]) -> Result<f64> {\n    let Ok(mut engine) = ocr::Engine::new() else {\n        bail!(\"Couldn't start OCR engine.\"); // should return an error if we don't have an OCR engine.\n    };\n\n    let num = ttf_parser::fonts_in_collection(data).unwrap_or(1);\n    let mut scale_context = swash::scale::ScaleContext::new();\n\n    for i in 0_usize..num as usize {\n        let Some(font_ref) = swash::FontRef::from_index(data, i) else {\n            continue;\n        };\n\n        let mut scaler = scale_context\n            .builder(font_ref)\n            .size(104.0)\n            .hint(true)\n            .build();\n        let charmap = font_ref.charmap();\n\n        // check ASCII codes, excluding space at 32\n        let mut full_image: Option<image::DynamicImage> = None;\n        for char in OCR_ASCII_VALIDATION_CHARACTERS.chars() {\n            let glyph_id = charmap.map(char);\n            let Some(image) = swash::scale::Render::new(&[swash::scale::Source::Outline])\n                .render(&mut scaler, glyph_id)\n            else {\n                bail!(\"Couldn't make glyph for: {char}\");\n            };\n            let Some(dynamic) =\n                image::GrayImage::from_raw(image.placement.width, image.placement.height, image.data)\n                    .map(image::DynamicImage::ImageLuma8)\n            else {\n                bail!(\"Couldn't copy swash image to image::DynamicImage.\")\n            };\n            if let Some(existing) = full_image.take() {\n                full_image = Some(append_right(&existing, &dynamic)?);\n            } else {\n                full_image = Some(dynamic);\n            }\n        }\n        let Some(full_image) = full_image else {\n            bail!(\"No atlas compiled.\");\n        };\n\n        let Ok(characters) = engine.process_impl(&full_image) else {\n            bail!(\"No characters read from atlas.\");\n        };\n        let characters: String = characters.iter().map(|character| character.char).collect();\n        return Ok(character_accuracy(\n            &characters,\n            OCR_ASCII_VALIDATION_CHARACTERS,\n        ));\n    }\n    bail!(\"Didn't find a good font.\")\n}\n```\n\nWe then pass the atlas (i.e., `full_image`\n\n) to our platform-specific `ocr::Engine`\n\nimplementation.\n\nIn 2026, macOS and Windows provide these facilities natively, and the Tritium implementation leverages those, while providing for a model-based approach on Linux.\n\nIn the production build, you would generally not want to re-instantiate the OCR engine for each check, but it may make sense given the infrequency with which embedded fonts are encountered in certain contexts.\n\nLast, we run the eval.\n\nOur simple testing harness looks like the following.\n\n``` js\n#[test]\nfn noto_font_has_ascii() {\n    let data = include_bytes!(\"fonts/noto.ttf\");\n    let accuracy = ascii_glyph_accuracy(data).expect(\"Glyphs should OCR.\");\n    assert!((accuracy == 1.0));\n}\n\n#[test]\nfn notoroboto_font_has_bad_ascii() {\n    let data = include_bytes!(\"fonts/noroboto.ttf\");\n    let accuracy = ascii_glyph_accuracy(data).expect(\"Glyphs should OCR.\");\n    assert!((accuracy < 1.0), \"got: {accuracy}\");\n}\n```\n\nWe confirm a perfect OCR for the ASCII portion of Google's Noto font, and an imperfect one for an example\n`noroboto`\n\nvariant which swaps the `M`\n\nand `D`\n\nUnicode code point and glyph.\n\nFortunately the replacement attack requires at least a single failure in OCR although identification cannot be deterministically guaranteed.\n\nTo support others in this effort, we are working on releasing a simple open-source reference implementation which will be added as an update to this post once available.\n\nWe look forward to community feedback on this consideration and response.\n\n-\nWe treat any embargo on the covered subject matter as having expired given prior art on 22 May 2025:\n\n[https://arxiv.org/pdf/2505.16957](https://arxiv.org/pdf/2505.16957)which we discovered during the course of this project.[↩](#ref-1) -\nSome might sneer at this proof-of-concept as \"AI slop\", but that's somewhat the point. While a lot of commentary following Project Glasswing and Mythos announcements were focused on the strength of that model, many folks rightly pointed out that off-the-shelf frontier models were capable of the same type of bug discovery. The \"Mythos moment\" for legal tech may in fact be the discovery that these types of attacks are trivial to produce given those same off-the-shelf models.\n\n[↩](#ref-2) -\nThis same result is achieved by the model in Tritium which does not provide any cipher tools.\n\n[↩](#ref-3) -\nAs an aside, this is not necessarily a total loss for the attacker who has now forced the opposition out of its comfort zone. The victim's pipeline will lose a lot of layout information supplied by the DOCX specification and be required to do its own segmentation to regain structure from the boxed-characters provided by the OCR. It may foreclose automated edit suggestions via Word add-ins, for example.\n\n[↩](#ref-4) -\nIt is worth noting that free-tier models which may or may not provide \"thinking\" modes often not only failed to summarize the obfuscated document but also hallucinated its content. One model suggested the disclosing party was \"Google, Inc.\"\n\n[↩](#ref-5) -\nThere are good data-protection reasons that one might legitimately obfuscate the text of its digital publications which we do not address here.\n\n[↩](#ref-6) -\nWe deliberately omit some of the technical requirements of this attack to avoid widespread replication. As noted above, even consumer-grade language models are capable of engineering these attacks with minimal guidance.\n\n[↩](#ref-7) -\nThis example has important legal consequences, but for a more lay example, imagine altering a dollar value in the same way. The human reviewer might see $2,000,000 while its LLM understood the price to be $1,000,000.\n\n[↩](#ref-8)", "url": "https://wpnews.pro/news/noroboto-lying-fonts-and-mitigation-in-rust", "canonical_source": "https://tritium.legal/blog/noroboto", "published_at": "2026-05-22 14:55:37+00:00", "updated_at": "2026-05-25 00:12:18.462428+00:00", "lang": "en", "topics": ["ai-safety", "ai-policy", "ai-ethics", "generative-ai", "large-language-models"], "entities": ["LegalQuants", "Noroboto", "SuperDoc", "PDF.js", "Office.js", "python-docx", "PDFium", "tesseract"], "alternates": {"html": "https://wpnews.pro/news/noroboto-lying-fonts-and-mitigation-in-rust", "markdown": "https://wpnews.pro/news/noroboto-lying-fonts-and-mitigation-in-rust.md", "text": "https://wpnews.pro/news/noroboto-lying-fonts-and-mitigation-in-rust.txt", "jsonld": "https://wpnews.pro/news/noroboto-lying-fonts-and-mitigation-in-rust.jsonld"}}