DOKIMASecurity review report

Overview

Attacker-controlled EXIF thumbnail-size drives unbounded (~4GB) allocation during JPEG thumbnail preview

medium Fully Valid high confidence

DOK-100108-FILEBROWSER-PREVIEW-THUMBNAILS-UNBOUNDED-EXIF-EMB · filebrowser · preview-thumbnails

Status

Fully Valid

Canonical of the EXIF pair. The unbounded attacker-controlled allocation (up to ~4GB) is confirmed in the go-exif dependency source on disk (make before the bounded ReadFull), the filebrowser path that reaches it is complete and default-reachable, and it bypasses the 10000x10000 decode guard. Vulnerable path complete in source => fully_valid; only the crash-vs-thrash impact mode is host-RAM-dependent and recorded as such.

Repository / Component

Repository
filebrowser
Component
preview-thumbnails

Plain-English Description

A specially crafted image can make the server try to reserve several gigabytes of memory just from reading its metadata. Any logged-in user allowed to download files can request a thumbnail of such an image and exhaust the server's memory, potentially crashing it for everyone.

Description of the Underlying Issue

When generating a low-quality (thumb) preview of a JPEG, img.Service.getEmbeddedThumbnail (img/service.go:167-176, 225-247) calls go-exif's exif.Collect on a ~64KB header buffer. During IFD1 enumeration, go-exif (v3.0.1) parseThumbnail (ifd_enumerate.go:592-599) reads the EXIF ThumbnailSize tag (0x0202) as an unbounded uint32 attacker value and sets it as the byte unit count. readRawEncoded (via ifd_tag_entry.go:168 -> common/value_context.go:154-165) then executes make([]byte, unitCount*1), allocating up to ~4.29GB, BEFORE the bounded io.ReadFull that is the only thing limited to the 64KB buffer. The surrounding recover() only catches the later ReadFull error — the oversized allocation has already been attempted.

Potential Attack

An authenticated user with Perm.Create uploads a small, valid JPEG (e.g. 1x1) whose EXIF IFD1 contains ThumbnailOffset(0x0201) pointing within the header and ThumbnailSize(0x0202)=0xFFFFFFFF. The small real dimensions pass detectFormat's 10000x10000 decode guard, so that guard does not bound this path (distinct sink from DOK-100025). The user (needing only Perm.Download) then requests GET /api/preview/thumb/<path>; createPreview calls Resize with QualityLow+FormatJpeg, getEmbeddedThumbnail runs, and exif.Collect drives the ~4GB make. Requests can be repeated and run at the default concurrency of 4, and results are not cached by default.

Outcomes of Potential Attack

Denial of service via memory exhaustion. On RAM-constrained hosts the allocation triggers Go's unrecoverable fatal 'out of memory' and crashes the process immediately; on large hosts it is a transient multi-GB allocation with heavy GC pressure, amplified by repeated requests and default-4 concurrency. Either way the server's availability is degraded or lost for all users.

Affected Scope

img.Service.getEmbeddedThumbnail -> go-exif exif.Collect (img/service.go:247); JPEG thumbnail preview path, any authenticated user with Perm.Download

Suggested Fix (plain english)

Before allocating memory for an embedded EXIF thumbnail, check that the claimed size fits within the small header buffer actually read, so a bogus size value cannot request gigabytes.

Suggested Fix (detailed)

Validate the embedded-thumbnail declared length against the actual available EXIF buffer size before allocating — cap it to the head buffer length (~64KB) in getEmbeddedThumbnail, or patch/upgrade go-exif so readRawEncoded checks the declared size against the reader length before make. Alternatively, stop trusting the EXIF embedded thumbnail entirely and always re-encode the thumbnail from a size-guarded decode. Add a test that feeds a small JPEG with ThumbnailSize=0xFFFFFFFF through the thumb preview path and asserts no oversized allocation occurs. Validate the embedded-thumbnail length against the actual available EXIF buffer size before allocating (cap it to the head buffer length), or patch/upgrade go-exif so readRawEncoded checks the declared size against the reader length before make. Alternatively, stop trusting the EXIF embedded thumbnail and always re-encode from a size-guarded decode.

Validation

Canonical of the EXIF pair. The unbounded attacker-controlled allocation (up to ~4GB) is confirmed in the go-exif dependency source on disk (make before the bounded ReadFull), the filebrowser path that reaches it is complete and default-reachable, and it bypasses the 10000x10000 decode guard. Vulnerable path complete in source => fully_valid; only the crash-vs-thrash impact mode is host-RAM-dependent and recorded as such.

first_opinion

Full Evidence

img/service.go:167-176 - getEmbeddedThumbnail runs when config.quality==QualityLow (thumb) and the DETECTED format==FormatJpeg; its result is written verbatim on success
img/service.go:225-247 - head=make([]byte,0xffff); single r.Read(head); exif.Collect(im, NewTagIndex(), head[offset:]) — the parse+alloc happens inside Collect
go-exif v3.0.1 ifd_enumerate.go:1319-1367,534-535 - Collect -> parseIfd; parseIfd calls parseThumbnail during IFD1 parse when ThumbnailOffset(0x0201)+ThumbnailSize(0x0202) are present
go-exif v3.0.1 ifd_enumerate.go:592-599 - parseThumbnail: length:=vList[0] (attacker uint32, no bound), updateTagType(TypeByte), updateUnitCount(length), GetRawBytes()
go-exif v3.0.1 ifd_tag_entry.go:168 -> common/value_context.go:154,159-165 - isEmbedded()==false for large unitCount; rawBytes=make([]byte, vc.unitCount*unitSizeRaw) (TypeByte size 1 => make([]byte, length), up to ~4GB) executes BEFORE io.ReadFull, which is the only thing bounded to the ~64KB buffer
http/preview.go:39,132-135,141 - previewHandler requires Perm.Download; PreviewSizeThumb sets WithQuality(QualityLow)+WithFormat(FormatJpeg); createPreview -> Resize(context.Background(), fd, ...)
http/http.go:84 + cmd/root.go:337,457 - EnableThumbnails = !disableThumbnails, default true (thumb path default-enabled); cmd/root.go:172 default NoOp cache => repeatable
Proven fact: go-exif's readRawEncoded allocates make([]byte, unitCount*unitSizeRaw) with unitCount set from the attacker-controlled ThumbnailSize tag (uint32, up to ~4.29GB) BEFORE the bounded io.ReadFull, and the surrounding recover() only catches the subsequent ReadFull error — the large allocation has already been attempted.
Proven fact: exif.Collect reaches parseThumbnail during IFD1 enumeration, so the allocation happens during the parse of the 64KB head buffer regardless of the later NextIfd()/Thumbnail() retrieval.
Proven fact: The path is default-reachable: an authenticated user with Perm.Download requesting a 'thumb' preview of a JPEG triggers getEmbeddedThumbnail; thumbnails are on by default and results are not cached by default.
Proven fact: A small real-dimensioned JPEG (e.g. 1x1) passes the detectFormat 10000x10000 guard while still carrying a crafted EXIF ThumbnailSize, so the decode dimension guard does not bound this allocation (distinct sink from DOK-100025).
Unvalidated fact: Which DoS mode results depends on host free memory: on RAM-constrained hosts the make triggers Go's unrecoverable fatal 'out of memory' (immediate crash); on large hosts it is a transient ~4GB allocation with heavy GC pressure, amplified by the default-4 concurrency and repeatable requests. Both are denial of service; the unbounded allocation itself is unconditional.
img/service.go:167-176 — getEmbeddedThumbnail runs when config.quality==QualityLow (thumb) and the DETECTED format==FormatJpeg; its result is written verbatim on success.
img/service.go:225-247 — head=make([]byte,0xffff); single r.Read(head); exif.Collect(im, NewTagIndex(), head[offset:]) — the parse+alloc happens inside Collect.
go-exif v3.0.1 ifd_enumerate.go:1319-1367,534-535 — Collect -> parseIfd; parseIfd calls parseThumbnail during IFD1 parse when ThumbnailOffset(0x0201)+ThumbnailSize(0x0202) are present.
go-exif v3.0.1 ifd_enumerate.go:592-599 — parseThumbnail: length:=vList[0] (attacker uint32, no bound), updateTagType(TypeByte), updateUnitCount(length), GetRawBytes().
go-exif v3.0.1 ifd_tag_entry.go:168 -> common/value_context.go:154,159-165 — isEmbedded()==false for large unitCount; rawBytes=make([]byte, vc.unitCount*unitSizeRaw) (TypeByte size 1 => make([]byte, length), up to ~4GB) executes BEFORE io.ReadFull.
http/preview.go:39,132-135,141 — previewHandler requires Perm.Download; PreviewSizeThumb sets WithQuality(QualityLow)+WithFormat(FormatJpeg); createPreview -> Resize(context.Background(), fd, ...).
http/http.go:84 + cmd/root.go:337,457 — EnableThumbnails = !disableThumbnails, default true; cmd/root.go:172 default NoOp cache => repeatable.
Unvalidated fact: Which DoS mode results depends on host free memory: on RAM-constrained hosts the make triggers Go's unrecoverable fatal 'out of memory' (immediate crash); on large hosts it is a transient ~4GB allocation with heavy GC pressure, amplified by default-4 concurrency and repeatable requests. Both are denial of service; the unbounded allocation itself is unconditional.
img/service.go:167-176 — getEmbeddedThumbnail invoked when config.quality==QualityLow (thumb) && detected format==FormatJpeg; result written verbatim on success
img/service.go:218-259 — getEmbeddedThumbnail: head=make([]byte,0xffff); single r.Read(head); exif.Collect(im, NewTagIndex(), head[offset:]) at :247 (parse+alloc happens inside Collect regardless of the later NextIfd()/Thumbnail() retrieval)
http/preview.go:132-135,141 — thumb path sets WithQuality(QualityLow); createPreview -> imgSvc.Resize(context.Background(), fd, ...)
http/http.go:84 + cmd/root.go:457 — previewHandler wired with server.EnableThumbnails; EnableThumbnails=!disableThumbnails default true => thumb path default-enabled
go-exif v3.0.1 ifd_enumerate.go:474-488,534-535 (/home/ubuntu/go/pkg/mod) — parseIfd stores thumbnail offset/size tags (IsThumbnailOffset/IsThumbnailSize) and calls parseThumbnail during parse of IFD1
go-exif v3.0.1 ifd_enumerate.go:577-602 parseThumbnail — length:=vList[0] (attacker uint32, no bound check); offsetIte.updateUnitCount(length); offsetIte.GetRawBytes()
go-exif v3.0.1 common/value_context.go:143-168 readRawEncoded — isEmbedded()==false for large unitCount, then rawBytes=make([]byte, vc.unitCount*unitSizeRaw) (TypeByte size 1 => make([]byte, length)) EXECUTED BEFORE io.ReadFull; the ReadFull is bounded to the ~64KB EXIF buffer, not the make
Refutes prior dismissal 'getEmbeddedThumbnail bounds EXIF parse to a 0xffff (64KB) head buffer' — the head bounds the read, not the go-exif-internal allocation