chk_defaced 0.1.0

Detect 'defaced' documents: verify the coherence of the fonts used to render text (PDF, DOCX, HTML+CSS) and build a JSON index of the system fonts' cmaps.
Documentation

chk_defaced

Detect "defaced" documents — files whose rendered (human-visible) content diverges from the machine-extracted content that downstream pipelines (search, RAG, LLM ingestion, e-discovery) actually consume. The attack almost always rides on the fonts: a glyph is drawn while a different codepoint is extracted.

Background and motivation — Dario Finardi, What You See Is Not What Your AI Reads: "Font remapping is an old trick. AI document pipelines just made it dangerous again." The document lies, and every component downstream faithfully passes the lie along. The attack family is named and demonstrated by the LegalQuants RED TEAM ("Noroboto"). See Background & references for the full reading list.

chk_defaced does two things:

  1. Builds a JSON index of the official cmaps of every font installed on your machine — the ground truth used to spot tampered fonts.
  2. Scans .pdf / .docx / .html documents and reports font-coherence and text/visual divergence anomalies. The core checks are deterministic — including outline-level detection of semantic replacement (variant A3) — and run with no OCR and no headless browser. OCR is an optional fallback behind a feature flag (see OCR fallback).

The core is pure Rust and offline by default; OCR is opt-in.


Build

cargo build --release

No native libraries are required (font parsing, PDF/ZIP/XML/HTML are all pure-Rust crates).

Usage

1. Build the system-font cmap index

# Defaults to the OS font directory (e.g. C:\Windows\Fonts) and writes fonts-index.json
chk_defaced build-registry --out fonts-index.json

# Smaller index (identity + hashes only, no full cmaps)
chk_defaced build-registry --slim --out fonts-index.json

Each entry records the font identity (family, subfamily, PostScript name, version, copyright, manufacturer), num_glyphs, units_per_em, the file SHA-256, the cmap SHA-256, and the full cmap as (codepoint, glyph_id) pairs.

2. Scan documents

# Human-readable report; pass the index to identify modified fonts
chk_defaced scan contract.pdf report.docx page.html --registry fonts-index.json

# Machine-readable
chk_defaced scan contract.pdf --registry fonts-index.json --format-out json

The process exits non-zero if any finding is High or worse (handy as a CI gate).

With an ocr-specimen build, add --ocr to escalate to specimen-OCR on documents where the deterministic pass finds no semantic replacement (the no-anchor case). It is skipped automatically on already-flagged files, degrades gracefully if Tesseract/tessdata is unavailable, and takes --ocr-lang (default ita+eng):

chk_defaced scan contract.pdf --ocr                 # ita+eng
chk_defaced scan vertrag.pdf  --ocr --ocr-lang deu+eng

What it detects (v1)

Rule Severity What it means
PDF.GLYPH_SEMANTIC_REPLACEMENT / DOCX.GLYPH_SEMANTIC_REPLACEMENT High Semantic replacement (variant A3). An embedded glyph that draws one letter is mapped from a different extracted character — the reader sees one word, extraction yields another, both lexically valid. Detected deterministically by cross-referencing each glyph's outline against the letters that reach it (no OCR, no rendering).
UNICODE.PUA High Private Use Area characters in the extracted text — unreadable without the font cmap (typical obfuscation).
UNICODE.ZERO_WIDTH / UNICODE.BIDI_OVERRIDE Medium Invisible / direction-reversing characters in the extracted text.
PDF.TOUNICODE_GARBLED High A PDF ToUnicode map sends codes to the PUA or to invisible/zero-width codepoints: glyphs render fine, extraction yields gibberish.
PDF.CUSTOM_DIFFERENCES Info Custom /Encoding /Differences — legitimate, but the vector for glyph↔codepoint redirects.
PDF.MANY_SUBSETS Medium One family split into many subset objects (possible per-page/per-run dynamic subsetting).
FONT.PUA_CMAP High A non-symbol embedded font whose internal cmap is mostly PUA — likely obfuscation.
DOCX.HIDDEN_VANISH Medium w:vanish runs: text present in the extract but not visible.
DOCX.FONT_UNREADABLE Low An obfuscated .odttf embedded font that could not be de-obfuscated/parsed.
HTML.HIDDEN_STYLE Medium CSS cloaking (display:none, opacity:0, off-canvas, font-size:0, …) on text.
HTML.CSS_CONTENT_INJECTION Low content: declarations inject visible text absent from the extracted DOM.
HTML.{REMOTE,DATAURI}_WEBFONT, HTML.FONTFACE_COUNT Info @font-face inventory; local fonts are verified, remote/data ones are only noted.

DOCX obfuscated embedded fonts (word/fonts/*.odttf) are de-obfuscated with the OOXML fontKey GUID before parsing.


Character coverage (v1.0)

The semantic-replacement outline cross-reference (*.GLYPH_SEMANTIC_REPLACEMENT) is scoped to the Latin script in v1.0. A character is a candidate iff char::is_alphabetic() and its Unicode script is Latin; everything else is simply not analyzed (no findings, no false positives).

✅ Checked — Latin-script letters (case-folded to lowercase):

Block Examples
Basic Latin a–z, A–Z
Latin-1 Supplement à á â ã ä å æ ç è é ê ë ì í î ï ñ ò ó ô õ ö ø ù ú û ü ý ÿ, ð þ ß
Latin Extended-A (Central/Eastern Europe) ā ă ą ć č ď đ ē ę ě ğ ı ł ń ň ő œ ŕ ř ś š ť ů ű ź ż ž
Latin Extended-B · IPA · Extended Additional (incl. Vietnamese) ƀ ɖ ɡ ơ ư ạ ẽ ị ọ ụ …

🚫 Excluded — not analyzed (out of v1.0 scope):

Class Examples
Greek / Coptic α β γ δ ε … ω
Cyrillic а б в г д … я, ӏ
Arabic (+ presentation forms) ا ب ت ث …, ﺁ ﺂ ﺃ …
Hebrew א ב ג ד …
CJK & Kana & Hangul 中 文, あ い, ア イ, 한 글
Armenian, Georgian, Devanagari, Thai, … ա բ, ა ბ, अ आ, ก ข
Non-letters digits 0–9, punctuation, math/currency symbols, emoji, whitespace

Analyzed but never flagged as tampering — within the Latin set, a glyph shared between two codepoints is legitimate glyph-sharing, not a finding, when they are: the same letter (or case variant); a TR39 confusable pair (homoglyphs) or the i/l pair; or compatibility-equivalent via NFKD — typographic ligatures (ff fi fl ffi ffl → their letters) and precomposed↔decomposed accents.

PUA/garbled checks additionally skip fonts that map glyphs to the Private Use Area legitimately: math fonts (Computer Modern / AMS — CMEX CMSY CMMI MSAM MSBM, stmary, wasy) and symbol/dingbat fonts (Symbol, Wingdings, Webdings, Marlett, …).

Finally, Latin letters that fold to the same ASCII base (ð/đ/ɖ → d, ø → o) are visually-confusable variants of one letter, so a shared glyph between them is not flagged either; a genuine swap (m vs d) folds differently and stays flagged. The deterministic scan is 0 false positives across the whole 32-document evaluation corpus.


OCR fallback (optional)

The deterministic outline checks catch semantic replacement (A3) without rendering. For the residual cases they cannot reach — fonts whose outlines are unreadable (Type3, bare CFF), or attacks that only survive a visual comparison — there is an opt-in OCR-atlas escalation: it rasterizes the page with pdfium, OCRs it with Tesseract 5.5 + Leptonica 1.85, and compares what is seen with what is extracted. It is OFF by default and lives behind feature flags:

# Pulls the native OCR backend (SemplificaAI/Tesseract5-rs) + pdfium rasterization
cargo build --release --features ocr-atlas

The native Tesseract build is described in TESSERACT-BUILD-HOWTO.md. Language models (*.traineddata) live in the per-arch cache …/tesseract-rs/<arch>/<mode>/tessdata/.

Specimen-OCR (ocr-specimen) — closing the no-anchor gap

The outline cross-reference needs the true letter to appear somewhere un-tampered (an "honest anchor") to form a collision. A fully custom font that remaps every code 1:1, with no honest occurrence, forms no collision and slips through. The specimen-OCR escalation fixes that: it rasterizes each embedded glyph straight from its outline (pure-Rust tiny-skia, no pdfium), OCRs it to recover what the glyph actually draws, and compares that to the character the document claims to extract — an independent ground truth that needs no anchor.

cargo run --example specimen --features ocr-specimen -- contract.pdf report.docx

It is high-recall (validated: 5/5 planted no-anchor lies caught). Two filters keep precision in check: ligature codepoints (which draw several letters, so single-letter OCR can never match) are excluded, and a per-word OCR confidence gate drops shaky reads — genuine drawn letters score ≥80 while the look-alike confusions (c/e, s/f, ı/l, a/d) score ≤47. Together these took a real LaTeX paper from 8 false positives to 0 with the true positives unchanged. The residual cost is recall: a glyph whose isolated shape OCRs below the gate (e.g. a double-story g) is skipped, not guessed. Still an opt-in escalation for the cases the deterministic detectors can't decide — not the default path.

OCR language — the example tools default to ita+eng and read the OCR_LANG environment variable (Tesseract's +-joined form). Install the *.traineddata you need in the tessdata dir, then:

# default: Italian + English
cargo run --release --features ocr-atlas --example atlas -- contract.pdf

# override (any installed languages, e.g. German + English)
OCR_LANG="deu+eng" cargo run --release --features ocr-atlas --example atlas -- vertrag.pdf

The tool is defensive: OCR returns plain glyph text only — nothing is ever fed to a model, and there is no network access at scan time.


Honest limitations (and roadmap)

This v1 is deliberately conservative to avoid false positives:

  • A font embedded in a PDF/DOCX is almost always a subset: its name table and internal cmap may be legitimately stripped, and glyph ids are renumbered. So a bare cmap-hash mismatch against the registry does not prove tampering — chk_defaced does not flag it. Semantic replacement is instead caught by the outline cross-reference (*.GLYPH_SEMANTIC_REPLACEMENT): the same drawn outline reachable from two different extracted letters is the tell, checked deterministically for both PDF and DOCX. To keep that precise on real documents, a collision is not flagged when the two letters are legitimate glyph-sharing rather than tampering: TR39 confusables, different scripts (Latin b / Greek β / Cyrillic в), or compatibility-equivalent encodings (Arabic base ↔ presentation forms, via NFKD). The ToUnicode arm of the check runs only for Type0/CID fonts, where a content-stream code is actually a glyph id (a simple font routes code → glyph through /Encoding).
  • v1.0 is scoped to the Latin script. The outline cross-reference only considers Latin letters (basic + accented + extended — all European languages); other scripts (Greek/Cyrillic/Arabic/CJK) are not analyzed, so they neither raise findings nor false positives.
  • False-positive rate is measured, not assumed. On a 32-document corpus of real public PDFs/DOCX (US + EU government, legal, academic, corporate), the deterministic scan is 0 false positives — down from 92 before the filters above. A genuinely-custom ToUnicode attack on a simple font is intentionally left to the OCR fallback rather than guessed at deterministically.
  • The residual cases the outline check cannot reach (unreadable outlines, visual-only attacks) are covered by the optional OCR fallback.
  • HTML uses a static pass only; the authoritative ground truth (rendered DOM, computed styles, downloaded webfonts, screenshot OCR) needs a headless browser — a later phase.
  • The tool is defensive: OCR returns plain glyph text only and nothing is ever fed to a model. No network access at scan time.

Performance

The deterministic scan (no OCR, no rendering) is CPU-only and fast. Measured on a 15-document corpus (PDF + DOCX) on a Snapdragon X Elite (Windows on ARM64), --release:

Time
Typical PDF / subset-font DOCX ~20–65 ms per document
Full-font (non-subset) DOCX, worst case ~0.1–0.9 s (every embedded glyph outline is hashed)
Corpus average ~120 ms / document

OCR, when enabled as a fallback, is the only heavy step (page rasterization + Tesseract); the deterministic checks above are what run on every document by default.

Evaluation corpus

The false-positive figures above are measured on a 32-document corpus of free, publicly-available documents (US + EU; companies, education, legal, public administration), chosen to stress the detector: multiple languages, font producers (LaTeX/Computer Modern, MS Word, InDesign, Acrobat), ligature/math fonts, subset vs full embedded fonts, and both PDF + DOCX. Only known-clean documents are collected (the false-positive set); the defaced/positive set is generated synthetically.

Region · Category Documents
US · public administration IRS forms: f1040, fw9, fw4, f941, f1099msc, Publication 17
US · legal govinfo public laws PLAW-117publ58 (IIJA), PLAW-116publ136 (CARES); US Code Title 17
US · education arXiv: 1706.03762 (Transformer), 1810.04805 (BERT), 1512.03385 (ResNet), 1409.1556 (VGG)
US · companies Berkshire Hathaway shareholder letters 2020 / 2021 / 2022
EU · legal EU Charter of Fundamental Rights — EN / FR / DE / IT / ES; Spanish gazette BOE-A-2018-16673
EU · public administration EU Commission White Paper on AI (2020); Banca d'Italia Relazione annuale 2022 (IT)
EU · companies Unilever Annual Report 2020; SAP Annual Report 2019
EU · education IZA (Bonn) discussion papers dp15000, dp16000; BIS (Basel) working paper work1000
misc · DOCX calibre demo; filesamples sample2, sample3 (Word-format coverage)

Total: 32 documents, ~41 MB. Reproducible and extensible via corpus/fetch.ps1 (idempotent; verifies magic bytes + SHA-256, writes corpus/manifest.csv). Sources are government, open-access research, official gazettes and published corporate reports. (Note: EUR-Lex blocks scripted download — the EU legal entries use the European Parliament Charter endpoint and national gazettes instead.)

Background & references

chk_defaced is the detection tool for the "Noroboto" attack family — documents whose fonts make the rendered text diverge from the AI-extracted text.

The attack disclosures — LegalQuants RED TEAM (legalquants.com):

  • Noroboto and the PDF that lied twice — Alexios vdSK, May 2026. Names and demonstrates the attack: "A PDF stores drawing commands, not text." A page that renders "$1,400,000" can carry a /ToUnicode CMap that extracts "$400…". The piece deliberately withheld the "surgical (partial-targeted) and replacement variants … until a mitigation system is broadly deployed at the ingestion layer" — which is exactly the layer this tool sits at.
  • The Contract That Could Get You Fired — Alexios vdSK & Iris Ng, June 2026. The Cyrillic-homoglyph variant: Latin letters in "Delaware" swapped for look-alike Cyrillic so byte-level search never matches — "the text is only what its bytes say it is, and a search engine compares bytes." This is precisely the case in our replaced.* test fixtures, and why chk_defaced filters real homoglyphs via Unicode TR39 skeletons.

The motivation write-up — Dario Finardi (Medium):

  • What You See Is Not What Your AI Reads (June 2026). Lays out the attack "below the prompt-injection layer" and three scenarios that map directly onto what this tool flags:

    Scenario What it does chk_defaced rules
    Full obfuscation whole text mapped to garbage codepoints UNICODE.PUA, PDF.TOUNICODE_GARBLED, FONT.PUA_CMAP
    Partial / targeted remapping a few numbers, names or clauses swapped, both readings lexically valid PDF.GLYPH_SEMANTIC_REPLACEMENT, DOCX.GLYPH_SEMANTIC_REPLACEMENT
    Per-subset manipulation different font subsets carry different maps across pages PDF.MANY_SUBSETS

    Its recipe — compare embedded mappings against the canonical font, flag divergences and re-read those spans visually, treat fully custom fonts as high-risk, and use classical OCR (Tesseract/PaddleOCR) rather than vision-language models — is exactly the design implemented here.

  • $35 Million. Destroyed by a Scanner. (June 2026). Companion piece: a PDF is a layered container (content, markup, navigation, structure), not a photograph — so what a pipeline extracts is not what a human sees.

Independent research on the same attack class:

License

Licensed under either of MIT or Apache-2.0, at your option.