diff --git a/services/thumbnails/pkg/preprocessor/rawtiff.go b/services/thumbnails/pkg/preprocessor/rawtiff.go index b974aade07..bcfce9836a 100644 --- a/services/thumbnails/pkg/preprocessor/rawtiff.go +++ b/services/thumbnails/pkg/preprocessor/rawtiff.go @@ -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 } diff --git a/services/thumbnails/pkg/preprocessor/rawtiff_test.go b/services/thumbnails/pkg/preprocessor/rawtiff_test.go index e6f16a61e7..dbcfd0b6f7 100644 --- a/services/thumbnails/pkg/preprocessor/rawtiff_test.go +++ b/services/thumbnails/pkg/preprocessor/rawtiff_test.go @@ -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()) + }) + }) + })