fix(thumbnails): harden the raw tiff walker against crafted files

Cap the IFD queue at maxIFDs so a file packed with huge SubIFD counts can no
longer grow it without bound, and walk the embedded JPEG by a bounded number
of header segments so a preview whose SOF sits past 64KB of leading metadata
is still found instead of being scanned only within a fixed byte window.
This commit is contained in:
Dominik Schmidt committed 2026-08-18 16:48:32 +02:00
1 parent 77b08abfb6
commit 61ce147205
2 files changed
+81 -11

No files matched your search

+16 -11
View File
@@ -42,9 +42,10 @@ const (
tiffTagJPEGLength = 0x0202 // JPEGInterchangeFormatLength
tiffTypeShort = 3
tiffTypeLong = 4
// cycle and decompression-bomb guard for untrusted IFD chains
maxIFDs = 64
sofScanLimit = 64 * 1024
// bound work on untrusted input: cap processed IFDs (and the queue) and
// the JPEG header segments walked before the SOF marker
maxIFDs = 64
maxJPEGSegments = 32
)
// extractEmbeddedJPEG walks the IFD chain incl. SubIFDs and returns the
@@ -72,6 +73,13 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) {
queue := []uint32{order.Uint32(data[4:8])}
seen := map[uint32]struct{}{}
// push queues an IFD offset unless the queue is already full; a crafted
// file with millions of SubIFD pointers must not grow it without bound
push := func(offset uint32) {
if len(queue) < maxIFDs {
queue = append(queue, offset)
}
}
first := true
for len(queue) > 0 && len(seen) < maxIFDs {
ifdOffset := queue[0]
@@ -123,16 +131,16 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) {
continue
}
if count == 1 {
queue = append(queue, order.Uint32(entry[8:12]))
push(order.Uint32(entry[8:12]))
continue
}
arrayOffset := order.Uint32(entry[8:12])
for j := uint32(0); j < count && j < maxIFDs; j++ {
pos := int(arrayOffset) + int(j)*4
if pos+4 > len(data) {
if pos+4 > len(data) || len(queue) >= maxIFDs {
break
}
queue = append(queue, order.Uint32(data[pos:pos+4]))
push(order.Uint32(data[pos : pos+4]))
}
}
}
@@ -142,7 +150,7 @@ func extractEmbeddedJPEG(data []byte) ([]byte, uint16, error) {
if stripOffset > 0 && stripLength > 0 {
candidates = append(candidates, candidate{stripOffset, stripLength})
}
queue = append(queue, order.Uint32(data[entriesEnd:entriesEnd+4]))
push(order.Uint32(data[entriesEnd : entriesEnd+4]))
first = false
}
@@ -170,11 +178,8 @@ func isRenderableJPEG(buf []byte) bool {
if len(buf) < 4 || buf[0] != 0xff || buf[1] != 0xd8 {
return false
}
if len(buf) > sofScanLimit {
buf = buf[:sofScanLimit]
}
i := 2
for i+4 <= len(buf) {
for segments := 0; i+4 <= len(buf) && segments < maxJPEGSegments; segments++ {
if buf[i] != 0xff {
return false
}
@@ -5,6 +5,7 @@ import (
"encoding/binary"
"image"
"image/jpeg"
"time"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@@ -156,4 +157,68 @@ var _ = Describe("RawTiffDecoder", func() {
Expect(bounds.Dy()).To(Equal(64))
})
})
Describe("hardening against crafted files", func() {
It("stays bounded when an IFD is packed with huge SubIFD counts", func() {
le := binary.LittleEndian
preview := encodeJPEG(64, 32)
entry := func(tag, typ uint16, count, value uint32) []byte {
e := le.AppendUint16(nil, tag)
e = le.AppendUint16(e, typ)
e = le.AppendUint32(e, count)
return le.AppendUint32(e, value)
}
const subEntries = 200
buf := []byte{'I', 'I', 42, 0, 8, 0, 0, 0}
ifdStart := uint32(len(buf))
entryCount := uint16(2 + subEntries)
previewStart := ifdStart + 2 + uint32(entryCount)*12 + 4
ifd := le.AppendUint16(nil, entryCount)
ifd = append(ifd, entry(tiffTagJPEGOffset, tiffTypeLong, 1, previewStart)...)
ifd = append(ifd, entry(tiffTagJPEGLength, tiffTypeLong, 1, uint32(len(preview)))...)
for i := 0; i < subEntries; i++ {
// each claims 4 billion SubIFD pointers at a bogus array offset
ifd = append(ifd, entry(tiffTagSubIFDs, tiffTypeLong, 0xffffffff, 8)...)
}
ifd = le.AppendUint32(ifd, 0)
buf = append(buf, ifd...)
buf = append(buf, preview...)
done := make(chan []byte, 1)
go func() {
jpg, _, _ := extractEmbeddedJPEG(buf)
done <- jpg
}()
select {
case jpg := <-done:
Expect(jpg).To(Equal(preview))
case <-time.After(5 * time.Second):
Fail("extractEmbeddedJPEG did not return within 5s on a crafted file")
}
})
})
Describe("isRenderableJPEG", func() {
It("reaches a SOF that lies past 64KB of leading segments", func() {
buf := []byte{0xff, 0xd8}
// two ~40KB APP1 segments push the SOF past the old 64KB window
for n := 0; n < 2; n++ {
const payload = 40000
buf = append(buf, 0xff, 0xe1, byte((payload+2)>>8), byte((payload+2)&0xff))
buf = append(buf, make([]byte, payload)...)
}
buf = append(buf, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0, 16, 0, 16, 0x01, 0x01, 0x11, 0x00)
Expect(isRenderableJPEG(buf)).To(BeTrue())
})
It("rejects a stream that hides the SOF behind too many segments", func() {
buf := []byte{0xff, 0xd8}
for n := 0; n < maxJPEGSegments+5; n++ {
buf = append(buf, 0xff, 0xe1, 0x00, 0x04, 0x00, 0x00) // tiny APP1
}
buf = append(buf, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0, 16, 0, 16, 0x01, 0x01, 0x11, 0x00)
Expect(isRenderableJPEG(buf)).To(BeFalse())
})
})
})