← All advisories
CVE-2026-55497Medium · CVSS 6.5· CWE-400, CWE-770

Denial of Service - Image decompression / pixel bomb in thumbnail & avatar decoding crashes the server

Vendor
cloudreve
Product
cloudreve
Status
Published · Jul 23 2026
Researchers
riodrwn
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:H
Published
Jul 23 2026

Summary

Cloudreve's built-in image processor decodes user-supplied images with Go's standard-library decoders (image/png, image/jpeg, image/gif) and guards only the compressed file size — never the decoded pixel dimensions. Go's decoders allocate a pixel buffer sized bytesPerPixel × width × height taken straight from the image header (e.g. a PNG's IHDR), with no upper bound on width/height. A tiny (tens-of-bytes) image that declares enormous dimensions therefore forces a multi-gigabyte-to-terabyte allocation (make([]uint8, …)), exhausting memory. The resulting out-of-memory condition is a fatal Go runtime error / kernel OOM-kill that recover() cannot catch, terminating the whole Cloudreve process for all users.

Two reachable sinks share the same root cause:

  1. Avatar upload (PUT /api/v4/user/setting/avatar) — decodes synchronously in the request handler. Any authenticated user. Cleanest single-request PoC.
  2. Thumbnail generation (built-in generator, enabled by default) — decodes in the thumbnail queue worker. Reachable for the user's own files and for files inside a share, so a planted bomb can be (re)triggered through a public share link.

Post-auth, low privilege. A single 65-byte upload deterministically takes the instance offline.

Details

Root cause — decode guarded by file size, not pixel count

The built-in generator is the default image thumbnailer:

// inventory/setting.go  @ 26b6b10
"thumb_builtin_enabled":   "1",         // ON by default
"thumb_builtin_max_size":  "78643200",  // 75 MB — a *file size* cap
"thumb_vips_enabled":      "0",         // libvips (which has its own limits) OFF by default
...
"avatar_size":             "4194304",   // 4 MB — a *file size* cap

Builtin.Generate checks the on-disk/entity size, then hands the raw bytes to the stdlib decoders:

// pkg/thumb/builtin.go:144-152  @ 26b6b10
func (b Builtin) Generate(ctx context.Context, es entitysource.EntitySource, ext string, previous *Result) (*Result, error) {
    if es.Entity().Size() > b.settings.BuiltinThumbMaxSize(ctx) {   // 75 MB compressed-size check ONLY
        return nil, fmt.Errorf("file is too big: %w", ErrPassThrough)
    }
    img, err := NewThumbFromFile(es, ext)   // <-- decode; allocation happens here
    ...
}
 
// pkg/thumb/builtin.go:34-60  @ 26b6b10
func NewThumbFromFile(file io.Reader, ext string) (*Thumb, error) {
    switch ext {
    case "jpg", "jpeg": img, err = jpeg.Decode(file)
    case "gif":         img, err = gif.Decode(file)
    case "png":         img, err = png.Decode(file)   // <-- unbounded allocation
    ...
    }
}

There is no image.DecodeConfig pre-check and no dimension/pixel cap anywhere on the path. The only Bounds()/MaxWidth references in the package (builtin.go:70,92,125, avatar_size_l=200) act on the already-decoded image and are the output resize target — they execute long after the oversized input buffer has been allocated.

Why the allocation is unbounded (Go stdlib image/png)

Go's PNG reader takes the dimensions verbatim from IHDR and rejects only non-positive values — there is no maximum:

// Go src/image/png/reader.go — parseIHDR
w := int32(binary.BigEndian.Uint32(d.tmp[0:4]))
h := int32(binary.BigEndian.Uint32(d.tmp[4:8]))
if w <= 0 || h <= 0 { return FormatError("non-positive dimension") }   // only guard
d.width, d.height = int(w), int(h)

On the first IDAT, readImagePass allocates the destination image before consuming the compressed pixel data, e.g. for colour-type 6 (RGBA, 8-bit):

// Go src/image/png/reader.go — readImagePass
nrgba = image.NewNRGBA(image.Rect(0, 0, width, height))   // make([]uint8, 4*width*height)

image.NewNRGBApixelBufferLengthmul3NonNeg(4, w, h) only guards against integer overflow (returns −1, which panics), not against huge-but-valid sizes. So any 4·w·h that fits in an int and is below the runtime's maxAlloc (~2⁴⁸ on amd64) proceeds to make([]uint8, 4·w·h). The allocation occurs even if the IDAT stream is empty/truncated, so the malicious file needs no real pixel data.

jpeg.Decode (allocates the YCbCr/RGBA buffer from the SOF/SOS dimensions, max 65535×65535 → up to ~17 GB) and gif.Decode (allocates from the logical-screen / frame dimensions) are affected the same way.

Why this is a fatal crash, not a handled error

For realistic bomb sizes (GBs–hundreds of GBs, all < maxAlloc), make proceeds and the process dies by one of:

  • Kernel OOM-killer sends SIGKILL when the touched pages can't be backed — not catchable by anything; or
  • the Go runtime throw("out of memory") — a fatal error, not a recoverable panic, so gin.Recovery() does not save it.

(Only the integer-overflow branch yields a recoverable panic; a competent attacker stays in the fatal-OOM regime, as the PoC does.)

Reachability of each sink

Avatar (synchronous, in the HTTP handler):

// routers/router.go:1269  @ 26b6b10  (under auth.Use(LoginRequired()) → user group)
setting.PUT("avatar", middleware.RequiredScopes(types.ScopeUserInfoWrite), controllers.UploadAvatar)
 
// service/user/setting.go:158-217  @ 26b6b10
func UpdateUserAvatar(c *gin.Context) error {
    ...
    if c.Request.ContentLength == -1 || c.Request.ContentLength > avatarSettings.MaxFileSize { // 4 MB cap
        return ...CodeFileTooLarge
    }
    return updateAvatarFile(c, u, c.GetHeader("Content-Type"), c.Request.Body, avatarSettings)
}
func updateAvatarFile(...) error {
    ext := "png"
    switch contentType { case "image/jpeg","image/jpg": ext="jpg"; case "image/gif": ext="gif" }
    avatar, err := thumb.NewThumbFromFile(file, ext)   // <-- decode of attacker bytes; no dim check
    ...
}

A 65-byte PoC trivially satisfies the 4 MB ContentLength cap.

Thumbnail (queue worker, in-process): manager.ThumbnailSubmitAndAwaitThumbnailTaskgenerateThumbpipeline.Generate (pkg/filemanager/manager/thumbnail.go:128-145, pkg/thumb/pipeline.go:86). The pipeline tries the enabled-by-default built-in generator for png/jpg/gif (other generators return ErrPassThrough for those extensions). Triggered via GET /api/v4/file/thumb?uri=… for the user's own files, and the same generation runs for files reached through a share (NavigatorCapabilityGenerateThumb), so a bomb dropped into a public share can be (re)triggered by an anonymous visitor.