Skip to content

linearize: /S cannot point inside the page offset table's own header - #47

Merged
tannevaled merged 1 commit into
mainfrom
feat/the-shared-table-cannot-start-inside-the-header
Nov 14, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/the-shared-table-cannot-start-inside-the-header

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

⭐ A lower bound on /S that needs no decoding — which is the part I had written off.

The previous change refused only a negative /S and recorded the rest as requiring the decoded stream. Half of it does not.

§F.3.6: the page offset hint table "shall be the first table in the stream and shall start at offset 0". Table F.3 gives that table's header as exactly thirteen items —

32, 32, 16, 32, 16, 32, 16, 32, 16, 16, 16, 16, 16 bits = 288 bits = 36 bytes

— before a single per-page entry. /S names where the shared object table begins, so it cannot be less than 36. That is arithmetic on two sentences of the spec: not a measurement, not a guess.

⛔ And 36 is exact, which two independent witnesses say

Measured across every producer available before choosing the number:

producer /S decoded
qpdf, table form 43 71
qpdf, object streams 43 71
Ghostscript 38 67

Ghostscript sits two bytes above the floor. A bound eyeballed from qpdf alone — 40, say — would have refused every file that tool writes. That is precisely the trap the /H exclusion walked into before a second producer existed, and this time the measurement came first.

⭐ Then the mutation found the second witness. Raising the bound to 37 reddens the boundary test and the generated qpdf sweep's one-page cases in all three shapes: qpdf emits /S 36 exactly for a single-page document. So a real producer sits on the floor, 37 would break it, and the corpus proved the off-by-one without being asked to.

Mutations

Judged by cmd/failset on the failing test names:

36 -> 37  →  …/36_is_the_floor_and_not_past_it, …//S_-1, …//S_35,
             AND TestCheckLinearizedAgreesWithQpdf… 1_pages × 3 shapes
36 -> 0   →  …//S_-1 and …//S_35 only — the floor case still passes,
             correctly, since 0 accepts 36

Both directions, which is what an off-by-one needs: one mutation refusing too much, one refusing too little, each red on different tests. A single mutation here would have left the boundary itself unproven.

⚠ The upper bound remains open

Unchanged reason: /S indexes the decoded data, so bounding it above means decoding an attacker's bytes in the cheap path — capped at 1 GiB by filter.go, which refuses a bomb but is three orders of magnitude past anything a hint stream needs. That wants a size bound derived from /N and the object count, which wants §F.4's layouts. The ordering is on #40.

gofmt clean, go vet clean, 0 uncovered blocks of 1812.

Refs #40.

🤖 Generated with Claude Code

⭐ A LOWER BOUND ON /S THAT NEEDS NO DECODING, which is the part I had written
off. The previous commit refused only a negative /S and recorded the rest as
needing the decoded stream. Half of it does not.

F.3.6: the page offset hint table "shall be the first table in the stream and
shall start at offset 0". Table F.3 gives that table's header as exactly
thirteen items — 32, 32, 16, 32, 16, 32, 16, 32, 16, 16, 16, 16, 16 bits =
288 bits = 36 bytes — before a single per-page entry. /S names where the
SHARED OBJECT table begins, so it cannot be less than 36. That is arithmetic
on two sentences of the spec, not a measurement and not a guess.

⛔⛔ AND 36 IS EXACT, WHICH TWO INDEPENDENT WITNESSES SAY. Measured before
choosing it, across every producer available:

    qpdf, table form      /S 43   (decoded 71 bytes)
    qpdf, object streams  /S 43   (decoded 71 bytes)
    Ghostscript           /S 38   (decoded 67 bytes)

Ghostscript sits two bytes above the floor, so a bound eyeballed from qpdf
alone — 40, say — would have refused every file that tool writes. That is the
trap the /H exclusion walked into before a second producer existed, and this
time the measurement came first.

⭐ Then the mutation found the other witness. Raising the bound to 37 reddens
the boundary test AND the generated qpdf sweep's ONE-PAGE cases in all three
shapes: qpdf emits /S 36 exactly for a single-page document. So a real
producer sits ON the floor, 37 would break it, and the corpus proved the
off-by-one without being asked to.

Mutations, judged by cmd/failset on the failing NAMES:

  36 -> 37  -> …/36_is_the_floor_and_not_past_it, …//S_-1, …//S_35,
               AND TestCheckLinearizedAgreesWithQpdf… 1_pages x 3 shapes
  36 -> 0   -> …//S_-1 and …//S_35 only — the floor case still passes,
               correctly, because 0 accepts 36

Both directions, which is what an off-by-one needs: one mutation that refuses
too much and one that refuses too little, each red on different tests.

⚠ THE UPPER BOUND REMAINS OPEN and the reason is unchanged: /S indexes the
DECODED data, so bounding it above means decoding an attacker's bytes in the
cheap path — capped at 1 GiB by filter.go, which refuses a bomb but is three
orders of magnitude past anything a hint stream needs. That wants a size bound
derived from /N and the object count, which wants F.4's layouts. The ordering
is on #40.

gofmt clean, go vet clean, 0 uncovered blocks of 1812.

Refs #40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled force-pushed the feat/the-shared-table-cannot-start-inside-the-header branch from 96033d6 to 189f8ba Compare November 14, 2026 21:32
@tannevaled
tannevaled merged commit 4f3c105 into main Nov 14, 2026
9 checks passed
@tannevaled
tannevaled deleted the feat/the-shared-table-cannot-start-inside-the-header branch November 14, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant