Skip to content

18 - Decode and decompression bombs

Objectives: Understand how a tiny input file can trick a decoder into allocating gigabytes of RAM, and learn the two concrete patterns this repo uses to prevent it — a cheap header-only pre-check for images and an aggregate byte budget for ZIP entries. Estimated time: 20 minutes.


What this actually means (plain English)

No jargon — here's what the ideas in this lesson actually mean, and why they matter.

  • A bomb is a ratio trick = "a postcard that claims to unfold into a skyscraper." A 42-byte ZIP or a 50 KB PNG header can declare gigabytes of output; the file is tiny, but trusting its declared size drives the allocator off a cliff.
  • Headers lie cheaply = "a contractor who quotes a price before measuring the job." Binary formats store width, height, or uncompressed size in a short header read in microseconds — trusting that number without a sanity check hands the attacker control of your RAM.
  • Decoding is the expensive step = "the moment the contractor actually buys all the materials." image.Decode and zip.Open allocate the memory the header claims; the guard in this lesson must fire before those calls, not after, or the damage is already done.
  • Per-entry limits are not enough for ZIP = "a bouncer who checks each guest's bag but never counts how many guests are inside." A ZIP bomb with 1 000 entries, each one byte under the per-entry cap, passes every individual check yet exhausts RAM; only an aggregate total accumulator across all entries stops it.
  • io.LimitReader is the one-liner fix = "a measuring cup with a fill line — anything over the line simply stops flowing." Wrap any reader in io.LimitReader(r, budget+1) and the Go stdlib stops the read at the budget, returning io.EOF instead of allocating forever.

Why it matters: a single malicious upload can take down a service that processes files without these guards; with them, the worst outcome is a rejected request and a logged error.

See it — the amplification. A bomb is a ratio: a tiny input declares an enormous output. The top path trusts that number and allocates it — OOM. The bottom path wraps the reader in io.LimitReader(r, budget+1), so the read stops at the budget and the decode is rejected before any giant allocation.

Decode bomb amplification, unguarded versus bounded A tiny input expands to gigabytes when trusted, causing OOM; wrapping the reader in io.LimitReader stops it at the budget. Unguarded — trusts the header 42-byte ZIPtiny header inflate claims 4.5 GB of output make([]byte, 4.5e9) allocated before a sanity check ✗ OOMprocess killed Bounded — io.LimitReader caps the read 42-byte ZIPsame input io.LimitReader( r, budget+1) reads ≤ budget, then stops ✓ ErrTooLarge — request rejected the guard must fire BEFORE image.Decode / zip.Open allocates — a header check is cheap, the allocation is not


Decode bombs: the image case

An image decode bomb works because image formats store width and height in a tiny header. A decoder (a piece of code that translates a file's raw bytes — its "byte" is the smallest unit a computer stores data in, 8 bits, and a "bit" is a single 0-or-1 switch — back into a usable picture) that trusts them blindly allocates (reserves a chunk of the computer's memory for) width × height × bytes_per_pixel bytes before it has read more than a kilobyte of input.

Go's image.DecodeConfig reads only the header and returns image.Config (width, height, colour model) without allocating the pixel buffer (the reserved block of memory that will hold the picture's raw pixel data). That makes it the perfect, cheap pre-check.

The guard: checkPixelLimit in stb-image-go/stb_image.go

A quick map before the code: var MaxImagePixels = 64 << 20 declares a package-level variable — a named value shared by every function in this file (a "package" is Go's word for a folder of source files that are compiled, i.e. translated from human-written text into a runnable program, together as one reusable unit). func checkPixelLimit(data []byte) error { ... } defines a function — a named, reusable block of steps — named checkPixelLimit that takes one input, a []byte (a "slice": a resizable, ordered list of bytes), and gives back ("returns" — the function finishes and hands its result to whoever ran/"called" it) a value of type error, which in Go is nil (a special "nothing here" value) when everything is fine, or a description of what went wrong when it is not:

// MaxImagePixels caps the number of pixels Load will decode, guarding against
// decode bombs — a tiny file whose header declares enormous dimensions can drive
// image.Decode to allocate gigabytes. The default is 64 megapixels (e.g.
// 8192x8192). Set it to 0 to disable the guard.
var MaxImagePixels = 64 << 20

func checkPixelLimit(data []byte) error {
    if MaxImagePixels <= 0 {
        return nil
    }
    cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
    if err != nil {
        return nil // let the full decode surface the real error
    }
    if cfg.Width > 0 && cfg.Height > 0 &&
        int64(cfg.Width)*int64(cfg.Height) > int64(MaxImagePixels) {
        return fmt.Errorf(
            "image %dx%d exceeds the %d-pixel decode limit (adjust MaxImagePixels)",
            cfg.Width, cfg.Height, MaxImagePixels)
    }
    return nil
}

In plain terms: this function peeks at just the image's header (skipping the expensive part) to read the claimed width and height; if multiplying them together gives more pixels than the allowed ceiling, it hands back an error describing the problem instead of letting the real decode run.

Two details are worth noting:

  1. int64 multiplication. cfg.Width and cfg.Height are plain int (32-bit on 32-bit platforms). Multiplying two large int32 values overflows silently, so the code casts to int64 first.
  2. Returning nil on DecodeConfig error. If the header is corrupt, image.DecodeConfig will fail. The function does not reject the input here — it lets image.Decode run and produce a proper error message. This avoids the trap of hiding real decode errors behind a spurious "pixel limit exceeded" message.

How Load uses the guard

func Load(data []byte) (image.Image, error) {
    if len(data) == 0 {
        return nil, errors.New("empty image data")
    }
    if err := checkPixelLimit(data); err != nil {
        return nil, err          // ← rejected before any pixel allocation
    }
    img, format, err := image.Decode(bytes.NewReader(data))
    // ...
    return img, nil
}

In plain terms: Load calls (runs) the guard function first; if the guard reports a problem, Load immediately hands that error back to whoever called it and stops — the expensive real decode never runs.

The guard is always called first. image.Decode is only reached if the declared dimensions are within budget.

Streaming variant: LoadStream

When you have an io.Reader (a general-purpose Go interface — a description of "anything that can produce a stream of bytes on demand," rather than one specific type of data) instead of a byte slice, you cannot rewind. The trick is to use io.TeeReader to record the bytes that DecodeConfig consumes, then replay them with io.MultiReader:

func LoadStream(r io.Reader) (image.Image, error) {
    if MaxImagePixels > 0 {
        var header bytes.Buffer
        cfg, _, cfgErr := image.DecodeConfig(io.TeeReader(r, &header))
        if cfgErr == nil && cfg.Width > 0 && cfg.Height > 0 &&
            int64(cfg.Width)*int64(cfg.Height) > int64(MaxImagePixels) {
            return nil, fmt.Errorf("image %dx%d exceeds the %d-pixel decode limit ...",
                cfg.Width, cfg.Height, MaxImagePixels)
        }
        // Replay the consumed header, then the remainder of the stream.
        r = io.MultiReader(&header, r)
    }
    img, _, err := image.Decode(r)
    // ...
}

In plain terms: since a stream of bytes can normally only be read once, front to back, this code makes a copy of the header bytes as they go by (io.TeeReader), checks that saved copy for a dimension bomb, and then glues that saved copy back onto the front of the remaining stream (io.MultiReader) so the real decoder still sees the whole file from the start.

TeeReader + MultiReader is a standard Go idiom for "peek at a reader without consuming it". You will see similar patterns in HTTP middleware that needs to inspect r.Body before passing it on.

Try it

Run the stb-image tests (a "test" here is a small piece of code, kept alongside the real program, whose only job is to run the program on a known input and check the output matches what's expected), which include a bomb-rejection case:

cd stb-image-go && go test -v -run TestLoad ./...

Expected: all tests pass. Look for a test case that checks an image whose declared dimensions exceed MaxImagePixels — it should return an error containing the words "exceeds the" without allocating any pixel memory.


Decompression bombs: the ZIP case

ZIP bombs work differently. The ZIP format stores UncompressedSize in each entry header, but that field is metadata — the real expansion happens during decompression. A classic zip bomb puts highly compressible data (e.g. a file of all-zero bytes) in each entry and nests or repeats it. The classic 42.zip is 42 bytes compressed; uncompressed it reaches 4.5 GB.

The per-stream guard: readAllLimited in miniz-go/miniz.go

// readAllLimited reads all of r, but errors instead of allocating without bound
// once the output would exceed limit. A limit <= 0 means unlimited.
func readAllLimited(r io.Reader, limit int64) ([]byte, error) {
    src := r
    if limit > 0 {
        // +1 so we can distinguish "exactly at the limit" from "over it".
        src = io.LimitReader(r, limit+1)
    }
    data, err := io.ReadAll(src)
    if err != nil {
        return nil, fmt.Errorf("read: %w", err)
    }
    if limit > 0 && int64(len(data)) > limit {
        return nil, fmt.Errorf(
            "decompressed size exceeds %d-byte limit (adjust MaxDecompressedSize)", limit)
    }
    return data, nil
}

In plain terms: this function reads everything out of a stream, but puts a hard ceiling (limit+1) on how much it will pull through; if what actually came out is bigger than the intended limit, it throws away the data and hands back an error instead of a giant buffer (a block of memory used to hold data temporarily while it's being read or processed).

The limit+1 trick is subtle: io.LimitReader returns io.EOF (Go's signal for "the stream has ended, there is nothing left to read") once the limit is reached, so if we passed limit we could not distinguish "exactly at the limit (fine)" from "one byte over (bad)". Passing limit+1 lets the underlying reader deliver up to one extra byte; if io.ReadAll comes back with more than limit bytes, we know the stream was truncated and we reject it.

The aggregate budget in ExtractArchive

A per-entry limit is necessary but not sufficient. Consider an archive with 1 000 entries, each one byte under the per-entry cap. Each entry passes individually, but together they exhaust RAM.

The fix is to track a running total across all entries and shrink the per-entry limit by the amount already consumed:

var MaxDecompressedSize int64 = 256 << 20  // 256 MiB default

var total int64  // aggregate decompressed bytes across all entries
for _, f := range r.File {
    var perEntryLimit int64
    if MaxDecompressedSize > 0 {
        if perEntryLimit = MaxDecompressedSize - total; perEntryLimit <= 0 {
            return nil, fmt.Errorf(
                "archive exceeds the %d-byte limit (adjust MaxDecompressedSize)",
                MaxDecompressedSize)
        }
    }

    rc, err := f.Open()
    // ...
    data, err := readAllLimited(rc, perEntryLimit)
    rc.Close()
    // ...
    total += int64(len(data))
}

In plain terms: for _, f := range r.File { ... } is a loop — a block that repeats once for every entry f in the archive's file list, in order. Each time round, it works out how much budget is left (MaxDecompressedSize - total), decompresses that one entry within whatever budget remains, and adds the entry's real size onto the running total — so the allowance for later entries keeps shrinking as earlier ones spend it.

Each iteration passes MaxDecompressedSize - total as the limit for that entry. Once the running total reaches the cap, perEntryLimit becomes zero or negative, and the loop rejects the next entry before even opening it.

The aggregate check was the bug

The original code capped each entry with a fixed MaxDecompressedSize limit. A multi-entry archive could silently exceed the budget because no one was tracking the running total. The audit (documented in docs/audits/2026-06-23-code-review-security-audit.md) flagged this as finding M5. The fix shown above — a total accumulator passed as the narrowing per-entry limit — was added alongside a regression test.

The global cap

// MaxDecompressedSize caps how many bytes ExtractArchive and DecompressData will
// produce, guarding against decompression bombs. For ExtractArchive the cap is
// on the ARCHIVE TOTAL across all entries, not per entry. The default is 256 MiB.
// Set it to 0 to disable.
//
// It must be set before any concurrent decompression begins; it is read without
// synchronization, so mutating it while a decompress is in flight is a data race.
var MaxDecompressedSize int64 = 256 << 20

The doc comment is explicit: this is an aggregate cap, not a per-entry cap, and mutating it mid-flight is a data race (a bug where two parts of the program run at the same time — "concurrently" — and touch the same shared value without coordinating, so the result depends on unpredictable timing). If you need a different limit per call site, you would need to pass it as a parameter — the global is a convenience for the common single-service case.

Stream decompression also checks the limit

DecompressStream uses the same variable for streaming output:

func DecompressStream(dst io.Writer, src io.Reader) error {
    r := flate.NewReader(src)
    defer r.Close()

    var reader io.Reader = r
    if MaxDecompressedSize > 0 {
        reader = io.LimitReader(r, MaxDecompressedSize+1)
    }
    n, err := io.Copy(dst, reader)
    if err != nil {
        return fmt.Errorf("decompress stream: %w", err)
    }
    if MaxDecompressedSize > 0 && n > MaxDecompressedSize {
        return fmt.Errorf("decompressed size exceeds %d-byte limit ...", MaxDecompressedSize)
    }
    return nil
}

In plain terms: this function decompresses a stream and writes the result straight out to dst (the destination) as it goes, wrapping the source in the same "stop at budget+1" limit reader; after the copy finishes it checks whether more bytes came through than allowed, and if so reports an error even though the copy already happened.

Same +1 trick, same post-copy check.

Try it

cd miniz-go && go test -v -run TestExtractArchive ./...

Expected: all tests pass, including one that crafts an archive whose total uncompressed size exceeds MaxDecompressedSize and expects an error containing "exceeds the".


Comparing the two patterns

Concern Image decode bomb ZIP decompression bomb
File format PNG / JPEG / GIF ZIP / DEFLATE
Attack vector Lies in the image header Highly compressible data in entries
Cheap pre-check image.DecodeConfig (header only) ZIP entry UncompressedSize field (unreliable; use after decompression)
Main guard checkPixelLimit before image.Decode readAllLimited with a shrinking per-entry budget
Global cap MaxImagePixels (pixel count) MaxDecompressedSize (byte count)
Aggregate tracking Not needed (single image) Essential (total accumulator)

Rule of thumb

For any format that has a cheap way to read declared size before doing real work: read the declared size first, compare it to a sane limit, reject early. io.LimitReader is always available as a belt-and-suspenders fallback during the actual read.


Putting it together: what happens on a real bomb

Here is the execution path when an attacker sends a 50 KB PNG claiming 65535 × 65535 pixels (about 4 billion pixels, ~12 GB at 3 bytes/pixel):

  1. Load(data) is called.
  2. checkPixelLimit(data) calls image.DecodeConfig — reads ~20 bytes of header.
  3. cfg.Width = 65535, cfg.Height = 65535.
  4. int64(65535) * int64(65535) = 4_294_836_225 > 64<<20 — check fails.
  5. Error returned: "image 65535x65535 exceeds the 67108864-pixel decode limit".
  6. image.Decode is never called. Zero bytes allocated for pixels.

And for a 1 000-entry ZIP bomb where each entry decompresses to 300 MiB:

  1. ExtractArchive loops over entries.
  2. Entry 0: perEntryLimit = 256 MiB - 0 = 256 MiB. Entry decompresses to 300 MiB → readAllLimited truncates at 256 MiB + 1 byte and returns an error on the int64(len(data)) > limit check.
  3. Extraction stops. Total allocation: at most ~256 MiB.

Try it — race detector

The image batch loader is concurrent (it runs several pieces of work at overlapping times rather than strictly one after another — each independent unit of that work is called a "goroutine" in Go). Verify the pixel-limit check is safe under concurrent load:

cd stb-image-go && go test -race ./...

Expected: no data race reported. MaxImagePixels is read (not written) inside checkPixelLimit, which is safe for concurrent reads. LoadStream and Load share no mutable state.


Key takeaways

  • Read the header cheap; reject before decoding. image.DecodeConfig costs almost nothing and reveals the declared dimensions before any pixel memory is allocated.
  • io.LimitReader(r, budget+1) is the canonical Go decompression guard. The +1 distinguishes "exactly at the limit" from "over it"; the post-read length check converts that into a clean error.
  • Per-entry limits alone do not stop multi-entry bombs. You need a running aggregate (total) that shrinks each entry's budget as bytes accumulate.
  • Both caps are package-level globals with documented data-race caveats. Set them once at startup; do not mutate them concurrently.
  • Zero allocation on rejection. When the guard fires, the expensive allocation (image.Decode, io.ReadAll on a decompressed stream) is never reached. That is the goal: fail fast and cheap, not after the damage is done.