Skip to content

Commit ad483ed

Browse files
authored
Cap ObjStm header prealloc
readCompressedObject sized its (objNum, offset) slice from the ObjStm's /N entry, which is attacker-controlled and bounded only by the decoded stream length. A few-KB PDF whose ObjStm inflates to 16 MiB of zeros and declares /N = 16M forced a ~256 MiB allocation before a single header entry was parsed. Cap the prealloc; append grows it to the real count. Adds regression tests at the Open()/Resolve() surface: an allocation bound for the hostile case and a >cap ObjStm that still resolves correctly, plus xref-recovery coverage (catalog-scan recovery and a /Prev incremental-update merge). PR #13
1 parent 8a18d87 commit ad483ed

2 files changed

Lines changed: 187 additions & 1 deletion

File tree

xref.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,10 @@ func (r *Reader) readCompressedObject(objStmNum, idx int, expect Reference) (Obj
444444
num int
445445
offset int
446446
}
447-
pairs := make([]pair, 0, n)
447+
// /N is attacker-controlled and only loosely bounded by len(content); a huge
448+
// value must not preallocate — append grows pairs to the real entry count.
449+
const maxObjStmPrealloc = 1 << 12
450+
pairs := make([]pair, 0, min(int(n), maxObjStmPrealloc))
448451
for i := int64(0); i < n; i++ {
449452
t1, err := lx.Next()
450453
if err != nil || t1.Kind != lex.Integer {

xref_test.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"compress/zlib"
66
"fmt"
7+
"runtime"
78
"testing"
89
)
910

@@ -214,6 +215,188 @@ func TestObjStmRejectsHostileHeader(t *testing.T) {
214215
}
215216
}
216217

218+
// buildObjStmHugeN builds a PDF reachable via an xref stream where object 4
219+
// is a type-2 entry inside ObjStm 3. The ObjStm decodes to contentLen zero
220+
// bytes (cheap: FlateDecode compresses them to a few KB) but declares
221+
// /N == contentLen. A reader that trusts /N as a slice capacity balloons the
222+
// few-KB file into contentLen*sizeof(pair) bytes before parsing a single entry.
223+
func buildObjStmHugeN(t *testing.T, contentLen int) []byte {
224+
t.Helper()
225+
var zbuf bytes.Buffer
226+
zw := zlib.NewWriter(&zbuf)
227+
if _, err := zw.Write(make([]byte, contentLen)); err != nil {
228+
t.Fatal(err)
229+
}
230+
zw.Close()
231+
compressed := zbuf.Bytes()
232+
233+
var buf bytes.Buffer
234+
buf.WriteString("%PDF-1.5\n%\xe2\xe3\xcf\xd3\n")
235+
236+
off3 := buf.Len()
237+
fmt.Fprintf(&buf, "3 0 obj\n<< /Type /ObjStm /N %d /First 4 /Filter /FlateDecode /Length %d >>\nstream\n",
238+
contentLen, len(compressed))
239+
buf.Write(compressed)
240+
buf.WriteString("\nendstream\nendobj\n")
241+
242+
off2 := buf.Len()
243+
rows := []byte{
244+
0x01, byte(off3 >> 8), byte(off3), 0x00, // obj 3: type 1 @ off3
245+
0x02, 0x00, 0x03, 0x00, // obj 4: type 2 -> ObjStm 3, index 0
246+
}
247+
fmt.Fprintf(&buf, "2 0 obj\n<< /Type /XRef /W [ 1 2 1 ] /Index [ 3 2 ] /Size 5 /Root 1 0 R /Length %d >>\nstream\n",
248+
len(rows))
249+
buf.Write(rows)
250+
buf.WriteString("\nendstream\nendobj\n")
251+
252+
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", off2)
253+
return buf.Bytes()
254+
}
255+
256+
func TestObjStmHugeNDoesNotAmplifyAllocation(t *testing.T) {
257+
const contentLen = 16 << 20 // == DefaultMaxStreamSize, the largest /N can be
258+
data := buildObjStmHugeN(t, contentLen)
259+
r, err := Open(bytes.NewReader(data))
260+
if err != nil {
261+
t.Fatalf("Open: %v", err)
262+
}
263+
defer r.Close()
264+
265+
var before, after runtime.MemStats
266+
runtime.GC()
267+
runtime.ReadMemStats(&before)
268+
_, err = r.Resolve(Reference{Number: 4, Generation: 0})
269+
runtime.ReadMemStats(&after)
270+
if err == nil {
271+
t.Fatal("expected an error resolving the hostile ObjStm entry, got nil")
272+
}
273+
// Decoding contentLen zero bytes legitimately costs ~2*contentLen; the
274+
// 256 MiB the /N prealloc would add is well past this ceiling.
275+
const limit = 128 << 20
276+
if used := after.TotalAlloc - before.TotalAlloc; used > limit {
277+
t.Fatalf("resolving a %d-byte ObjStm allocated %d bytes (> %d limit); /N is amplifying allocation",
278+
contentLen, used, limit)
279+
}
280+
}
281+
282+
// Capping the prealloc must not truncate a legitimate ObjStm whose entry count
283+
// exceeds the cap: object 100+i carries the value 100+i, and resolving one past
284+
// the cap must still return its exact value (proving append grew the slice).
285+
func TestObjStmManyObjectsResolvePastPrealloc(t *testing.T) {
286+
const m = 5000 // > the internal maxObjStmPrealloc (4096)
287+
288+
var body bytes.Buffer
289+
offsets := make([]int, m)
290+
for i := 0; i < m; i++ {
291+
offsets[i] = body.Len()
292+
fmt.Fprintf(&body, "%d ", 100+i)
293+
}
294+
var head bytes.Buffer
295+
for i := 0; i < m; i++ {
296+
fmt.Fprintf(&head, "%d %d ", 100+i, offsets[i])
297+
}
298+
content := head.String() + body.String()
299+
300+
var buf bytes.Buffer
301+
buf.WriteString("%PDF-1.5\n%\xe2\xe3\xcf\xd3\n")
302+
off1 := buf.Len()
303+
fmt.Fprintf(&buf, "1 0 obj\n<< /Type /ObjStm /N %d /First %d /Length %d >>\nstream\n",
304+
m, head.Len(), len(content))
305+
buf.WriteString(content)
306+
buf.WriteString("\nendstream\nendobj\n")
307+
308+
last := 100 + m - 1
309+
off2 := buf.Len()
310+
rows := []byte{
311+
0x01, byte(off1 >> 8), byte(off1), 0x00, 0x00, // obj 1: type 1 @ off1
312+
0x02, 0x00, 0x01, byte((m - 1) >> 8), byte((m - 1) & 0xff), // last obj: type 2 -> ObjStm 1, idx m-1
313+
}
314+
fmt.Fprintf(&buf, "2 0 obj\n<< /Type /XRef /W [ 1 2 2 ] /Index [ 1 1 %d 1 ] /Size %d /Root 1 0 R /Length %d >>\nstream\n",
315+
last, last+1, len(rows))
316+
buf.Write(rows)
317+
buf.WriteString("\nendstream\nendobj\n")
318+
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", off2)
319+
320+
r, err := Open(bytes.NewReader(buf.Bytes()))
321+
if err != nil {
322+
t.Fatalf("Open: %v", err)
323+
}
324+
defer r.Close()
325+
v, err := r.Resolve(Reference{Number: last, Generation: 0})
326+
if err != nil {
327+
t.Fatalf("Resolve object %d: %v", last, err)
328+
}
329+
n, ok := v.(Integer)
330+
if !ok || int(n) != last {
331+
t.Fatalf("object %d resolved to %v (%T), want Integer %d", last, v, v, last)
332+
}
333+
}
334+
335+
// With no startxref and no "trailer" keyword, recovery must scan the rebuilt
336+
// objects for a /Type /Catalog and synthesise a trailer pointing at it.
337+
func TestRecoverXrefViaCatalogScan(t *testing.T) {
338+
var buf bytes.Buffer
339+
buf.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
340+
buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
341+
buf.WriteString("2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n")
342+
buf.WriteString("%%EOF\n")
343+
344+
r, err := Open(bytes.NewReader(buf.Bytes()))
345+
if err != nil {
346+
t.Fatalf("Open: %v", err)
347+
}
348+
defer r.Close()
349+
cat, err := r.Catalog()
350+
if err != nil {
351+
t.Fatalf("Catalog: %v", err)
352+
}
353+
if n, ok := cat.Name("Type"); !ok || n != "Catalog" {
354+
t.Fatalf("/Type %q ok=%v, want Catalog", n, ok)
355+
}
356+
}
357+
358+
// An incremental update appends a second xref section whose /Prev points back
359+
// at the first. The newer section must win: object 1 resolves to its updated
360+
// body, and trailer keys present only in the older section still resolve.
361+
func TestPrevChainNewestSectionWins(t *testing.T) {
362+
var buf bytes.Buffer
363+
w := func(s string) int { off := buf.Len(); buf.WriteString(s); return off }
364+
w("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
365+
off1v1 := w("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
366+
off2 := w("2 0 obj\n<< /Type /Pages /Count 0 /Kids [] >>\nendobj\n")
367+
368+
xref1 := buf.Len()
369+
fmt.Fprintf(&buf, "xref\n0 3\n%010d %05d f \n%010d %05d n \n%010d %05d n \n",
370+
0, 65535, off1v1, 0, off2, 0)
371+
buf.WriteString("trailer\n<< /Size 3 /Root 1 0 R /Info 2 0 R >>\n")
372+
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xref1)
373+
374+
off1v2 := buf.Len()
375+
buf.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Lang (en-US) >>\nendobj\n")
376+
xref2 := buf.Len()
377+
fmt.Fprintf(&buf, "xref\n1 1\n%010d %05d n \n", off1v2, 0)
378+
fmt.Fprintf(&buf, "trailer\n<< /Size 3 /Root 1 0 R /Prev %d >>\n", xref1)
379+
fmt.Fprintf(&buf, "startxref\n%d\n%%%%EOF\n", xref2)
380+
381+
r, err := Open(bytes.NewReader(buf.Bytes()))
382+
if err != nil {
383+
t.Fatalf("Open: %v", err)
384+
}
385+
defer r.Close()
386+
387+
cat, err := r.Catalog()
388+
if err != nil {
389+
t.Fatalf("Catalog: %v", err)
390+
}
391+
if lang, ok := cat.String("Lang"); !ok || lang != "en-US" {
392+
t.Errorf("/Lang = %q ok=%v, want en-US (updated object 1 not used)", lang, ok)
393+
}
394+
// /Info lives only in the older trailer; the merge must preserve it.
395+
if _, ok := r.Trailer().Get("Info"); !ok {
396+
t.Error("older trailer's /Info lost after /Prev merge")
397+
}
398+
}
399+
217400
// A classical xref subsection declaring far more entries than the file holds
218401
// must be handled gracefully (recover), not over-read the buffer. The bound is
219402
// also overflow-safe on 32-bit by construction (untestable on a 64-bit run).

0 commit comments

Comments
 (0)