isthisazipbomb.com
Drop an archive and find out whether unpacking it will destroy your machine — without unpacking it.
- Started
- Released
A ZIP bomb is a small file that expands into an enormous one. 42.zip is 42 KB and unpacks
to 4.5 petabytes. The only safe way to find out what an archive contains is to read its
structure rather than its contents — and that is all this tool does.
The verdict#
The analyzer returns a risk score, a confidence figure, and — more importantly — the reasoning behind both. A number on its own is not actionable; the signals that produced it are.
┌──────────────────────────────────────────────┐
│ 🔴 Likely ZIP Bomb │
│ │
│ RISK SCORE CONFIDENCE │
│ 86% 85% │
└──────────────────────────────────────────────┘
Why?
✓ Compression ratio: 1,029 : 1 +30.2
✓ Expansion confirmed by decompression, not just by metadata +25
✓ Estimated extracted size: 1.0 GB (reported by the archive) +16.2
✓ Decompressed entropy: 0.00 bits/byte +15
Architecture#
Browser ──▶ Caddy ──┬──▶ web (Next.js, static page)
└──▶ api (Go, the analyzer)
Three containers. Only Caddy publishes a port; web and api sit on an internal Docker
network with no gateway, so neither can reach the internet.
Why Caddy is in the path#
The obvious design proxies /api through Next.js with a rewrites() entry — one fewer
container, same single origin. That was built first, and it does not work: Next's
rewrite proxy drops large request bodies, so a 13 MB upload stalls for 30 seconds and dies
with socket hang up.
Caddy streams uploads straight to the analyzer instead. Node never allocates a byte for them.
The analyzer#
bytes
├─▶ sniff ──────────▶ format magic bytes only; the filename is ignored
├─▶ open ───────────▶ structure central directory / headers, zero inflation
├─▶ walk ───────────▶ entries budgeted, depth-limited, streaming
│ ├─▶ probe nested archive? entropy?
│ ├─▶ verify measured expansion vs. declared
│ └─▶ identify self-reference (quine) detection
├─▶ aggregate ──────▶ metrics
├─▶ score ──────────▶ weighted signals
└─▶ classify ───────▶ risk · confidence · findings · advice
The Go module has no dependencies beyond the standard library. Every third-party package would be another parser running against deliberately hostile input, so the bar for adding one is high.
Security model#
Nothing is written to disk — ever#
Enforced structurally rather than by policy:
- Uploads are read into a bounded in-memory buffer, freed when the request returns.
- The handler never calls
ParseMultipartFormorFormFile. Both spill parts larger than their memory threshold to temporary files, which would break the guarantee silently.r.MultipartReader()is used directly instead. - A test enforces it.
nodisk_test.goparses every non-test source file and fails the build if it findsos.Create,os.CreateTemp,os.WriteFile,exec.Command,ParseMultipartForm,FormFile, or friends. - The container's root filesystem is read-only, with
/tmpa 16 MBnoexectmpfs.
"Uploaded files are deleted after analysis" is a weaker claim than what happens here: no file is ever created, so there is nothing to delete.
Nothing is decompressed without a budget#
Every decompressing reader is wrapped in a safety.LimitedReader tied to one shared
Budget covering the entire recursive walk — not per entry, since 200,000 entries each
claiming the maximum is the same attack.
The reader has one deliberate difference from io.LimitedReader: at its cap it returns
ErrBudgetExceeded, not io.EOF. A silent EOF would make a bomb indistinguishable from
a small honest file — the caller reads 1 MB, sees a clean end of stream, and concludes the
entry was 1 MB.
Limits#
Hitting a limit is a result, not an error. "I stopped counting at 512 MB, and it was still expanding" is one of the most useful things this tool can say.
| Setting | Default | Bounds |
|---|---|---|
MAX_UPLOAD_BYTES | 100 MB | Request body, held in memory only |
MAX_INFLATE_BYTES | 512 MB | Total decompression across all depths |
MAX_ENTRIES | 200,000 | Entries inspected |
MAX_DEPTH | 8 | Nesting levels |
MAX_NESTED_ARCHIVES | 64 | Nested archives opened |
VERIFY_BYTES | 32 MB | Per-entry verification decompression |
ANALYZE_TIMEOUT | 20s | Wall clock per request |
MAX_CONCURRENT | 4 | In-flight analyses |
RATE_LIMIT_RPM | 20 | Requests per minute per client |
Worst-case memory is MAX_UPLOAD_BYTES × MAX_CONCURRENT = 400 MB, against a 1 GB
container limit. The figure is logged at boot so an operator does not have to derive it from
four separate settings.
Container hardening#
The api container reads hostile input for a living, so it is locked down hardest:
FROM gcr.io/distroless/static:nonroot— no shell, no package manager, no interpreter. A parser bug reaching code execution finds nothing to execute.read_only: true,cap_drop: [ALL],no-new-privileges,pids_limit: 128.- Attached only to a network with
internal: true— no gateway, no egress. An archive that somehow achieved code execution has nothing to call home to.
What this protects against#
| Attack | Control |
|---|---|
| Disk exhaustion | Nothing is written; rootfs is read-only |
| Memory exhaustion | Byte budget on every decompress; container limit |
| CPU exhaustion | Per-request deadline, entry/depth caps, CPU quota |
| Path traversal, symlink escape | Detected and reported; nothing is extracted, so nothing suffers |
| Malformed archives | Defensive parsing, checked arithmetic, per-request recover() |
| Parser confusion | Central and local ZIP headers cross-checked, disagreement flagged |
| SSRF from archive contents | The analyzer container has no route off its network |
| Denial of service | Per-client token bucket + global concurrency semaphore |
| Cross-request data leakage | No disk, no cache, no shared state; one buffer per request |