tounicode: bound how many codes one map may name - #3
Merged
Conversation
A bfrange names a run of codes in about twenty bytes. The width of a single run was capped and the number of runs was not, so the size of the answer had nothing to do with the size of the question: 585 bytes produced 655 360 entries in 2.3 seconds, and 10 655 bytes produced 13 107 200 entries, a gigabyte of memory and ten seconds. Every font on every page is read this way, so that was a gigabyte per font. The bound is not a guess. Across 5 338 /ToUnicode maps taken out of real documents the median names 13 codes, the 99th percentile names 538, and the largest names exactly 65 536 — one whole two-byte code space, which is as many codes as a font of that shape can have. This allows four times that, so a document has to be malformed or hostile to reach it. All 5 338 come back byte for byte identical. 585 bytes 2.3 s, 63 MB -> 47 ms, 31 MB 2 705 bytes 14.6 s, 264 MB -> 52 ms, 31 MB 10 655 bytes 10.3 s, 1054 MB -> 52 ms, 31 MB 106 055 bytes -> 48 ms, 34 MB The regression test reports 655 360 entries from 585 bytes against the parent commit, taking 229 seconds to do it, and then times the suite out at five minutes. It runs in 0.66 s here. This commit also adds the fuzz targets. There were none in this repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reader v0.4.1 stopped answering "which objects call themselves a catalogue?" by counting from zero to the largest object number a file names. This package was still asking for v0.4.0, so it still handed its callers the defect. bug1980958.pdf in mozilla's pdf.js corpus is 219 bytes. It has no trailer and no startxref, so it can only be read by repairing it, and the last object it declares is numbered 2 147 483 647. Two thousand million map lookups for four objects: reader.Open on 219 bytes 21.2 s, 0 MB allocated -> under 1 ms Not one byte allocated, which is why no memory limit anywhere caught it. The test builds the file rather than committing somebody else's, and guards the dependency rather than this package's own code. It fails in 15.1 s against the parent commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled
force-pushed
the
battle-cmap-bound
branch
from
August 27, 2026 08:13
2c876ed to
cacef91
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by a robustness campaign against mozilla's pdf.js adversarial corpus (977 files), 2 268 public forms from 18 issuers (IRS, SSA, OPM, DOL, USCIS, US Courts, GOV.UK, CRA, CERFA, impots.gouv.fr, WIPO — plus the deliberately broken suites of veraPDF, qpdf, pdfbox, pypdf, pdfcpu and openpdf), and 33 628 arXiv figure PDFs.
The bomb
A
bfrangenames a run of codes in about twenty bytes:readBFRangecaps the width of a single run at 65 536. Nothing capped how many runs a map could hold. So the size of the answer had nothing to do with the size of the question:A ~104 000x amplification by memory, and easy to reach:
pdffont.ReadthenreadToUnicodethenReadToUnicoderuns for every font on every page, andextractreads every font on every page. A 10 KB font object costs a gigabyte; a document with twenty such fonts costs twenty.After
The cost now stops growing: two hundred times the input buys the same answer.
The bound is measured, not guessed
Across 9 183 distinct
/ToUnicodemaps harvested from two independent corpora (deduplicated by content hash):The two corpora agree separately as well: 5 338 maps from pdf.js + arXiv (median 13, max 65 536) and 4 378 from the government-forms corpus (median 35, max 65 536). Neither produced a single map above 65 536, which is one whole two-byte code space — as many codes as a font of that shape can have.
The bound is
1 << 18, four times the largest map either corpus produced, so a document has to be malformed or hostile to reach it.All 5 338 of the first corpus come back byte for byte identical. The A/B compares the sorted key/value pairs of every map before and after; the diff is empty.
The regression test
Against the parent commit,
TestToUnicodeIsBoundedByItsOwnSizereports585 bytes named 655360 codes, over the 262144 bound— taking 229 seconds to get there — andTestToUnicodeCostIsBoundedthen times the whole suite out at five minutes:Here the whole package runs in 0.66 s.
The tests cover both shapes the attack takes — many blocks of one range each, and one block naming many — plus a block entered when the map is already full, and check that the maps anybody actually has still read: a full two-byte code space still comes back with all 65 536 codes, and a range still counts on from its first character.
Second commit: a stale dependency shipping a known denial of service
readerv0.4.1 stopped answering "which objects call themselves a catalogue?" by counting from zero to the largest object number a file names. This package was still asking for v0.4.0, so it still handed its callers the defect.bug1980958.pdfin the pdf.js corpus is 219 bytes. No trailer, no startxref — so it can only be read by repairing it — and the last object it declares is numbered 2 147 483 647. Two thousand million map lookups for four objects:Not one byte allocated, which is why no memory limit anywhere caught it. Measured with
GOMAXPROCS(1): a goroutine waiting for a core cannot tell waiting from working.TestATinyFileWithAHugeObjectNumberbuilds the file rather than committing somebody else's, and guards the dependency rather than this package's own code. It fails in 15.1 s against the parent commit.Fuzz targets
There were none in this repository. This adds
FuzzReadToUnicodeandFuzzRuneOfGlyphName. Both assert a time budget as well as absence of panic, because a cost that grows without the input growing raises nothing on its own — which is exactly how this defect survived. PointCMAP_SEEDSat a directory of real CMaps to seed from a corpus; without it the built-in seeds and anything undertestdatastill run.What else was looked for and not found
ReadToUnicodeserially: worst case 10.6 MB from a 219 KB input, nothing over 54 ms, no panics. (Measured one at a time — an earlier concurrent run made small maps look expensive, becauseruntime.MemStats.TotalAllocis process-wide.)pdffont.ReadplusWidth,Text,GlyphNameandCIDToGIDover the code space: no panics, no timeouts.pdffont.Read's lookups allocate nothing — measured at zero bytes. An earlier 823 MB figure turned out to be the harness calling 16.8 million of them without caching, not the library.Gates
go test ./...green, 100.0% of statementsgo vetclean,gofmtclean,CGO_ENABLED=0🤖 Generated with Claude Code