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.Decodeandzip.Openallocate 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
totalaccumulator across all entries stops it. io.LimitReaderis the one-liner fix = "a measuring cup with a fill line — anything over the line simply stops flowing." Wrap any reader inio.LimitReader(r, budget+1)and the Go stdlib stops the read at the budget, returningio.EOFinstead 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 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:
int64multiplication.cfg.Widthandcfg.Heightare plainint(32-bit on 32-bit platforms). Multiplying two largeint32values overflows silently, so the code casts toint64first.- Returning
nilonDecodeConfigerror. If the header is corrupt,image.DecodeConfigwill fail. The function does not reject the input here — it letsimage.Decoderun 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:
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
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):
Load(data)is called.checkPixelLimit(data)callsimage.DecodeConfig— reads ~20 bytes of header.cfg.Width = 65535,cfg.Height = 65535.int64(65535) * int64(65535) = 4_294_836_225 > 64<<20— check fails.- Error returned:
"image 65535x65535 exceeds the 67108864-pixel decode limit". image.Decodeis never called. Zero bytes allocated for pixels.
And for a 1 000-entry ZIP bomb where each entry decompresses to 300 MiB:
ExtractArchiveloops over entries.- Entry 0:
perEntryLimit = 256 MiB - 0 = 256 MiB. Entry decompresses to 300 MiB →readAllLimitedtruncates at 256 MiB + 1 byte and returns an error on theint64(len(data)) > limitcheck. - 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:
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.DecodeConfigcosts 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+1distinguishes "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.ReadAllon a decompressed stream) is never reached. That is the goal: fail fast and cheap, not after the damage is done.