Skip to content
80MB ZIP Bomb Exhausts 20GB Disk: Secure Extraction Practices for Spring Boot

80MB ZIP Bomb Exhausts 20GB Disk: Secure Extraction Practices for Spring Boot

This article details how an 80MB ZIP bomb exhausted a 20GB disk in a Spring Boot service, explaining ZIP bomb mechanics, Zip Slip directory traversal, and providing secure extraction code with limits on total uncompressed size, per-file size, entry count, compression ratio, and path normalization.

LuTiao Programming
LuTiao Programming
LuTiao Programming

An 80MB ZIP upload caused a Spring Boot service to crash with java.io.IOException: No space left on device. The 20GB temporary disk was completely filled, even though the uploaded file was only 80MB. Investigation revealed the ZIP expanded far beyond its compressed size due to extremely high compression ratios — a classic ZIP bomb attack.

OWASP and ZIP Bombs

OWASP's Web Security Testing Guide still lists ZIP bombs as a file upload risk. A ZIP bomb exploits extremely high compression ratios (e.g., repeated data, zero bytes) to make a tiny archive expand to massive size on extraction, exhausting disk or memory.

Original Extraction Code Looked Safe

The initial extraction code used ZipInputStream to stream entries one by one, avoiding loading the entire archive into heap:

public void unzip(InputStream input, Path targetDir) throws IOException {
    try (ZipInputStream zis = new ZipInputStream(input)) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            Path target = targetDir.resolve(entry.getName());
            if (entry.isDirectory()) {
                Files.createDirectories(target);
                continue;
            }
            Files.createDirectories(target.getParent());
            Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
            zis.closeEntry();
        }
    }
}

This code is streaming and doesn't use readAllBytes(), so it avoids OOM. However, streaming only prevents loading all data into memory at once — it does not limit the total amount of data written to disk. If the ZIP expands to 30GB, Files.copy() will write 30GB until the disk fills.

Compressed Size ≠ Uncompressed Size

ZIP compression ratios vary wildly. Already-compressed media (photos, videos) shrink little, but highly repetitive data (repeated characters, zero bytes) can achieve enormous ratios. A simple check like if (file.getSize() > MAX_ZIP_SIZE) only limits the uploaded compressed size, not the final uncompressed data volume. An 80MB ZIP could contain a single entry that expands to 10GB. The real resource consumption is the peak resource usage during processing , not the network upload size.

First Limit: Total Uncompressed Size

The first mitigation added was a cumulative byte counter during extraction. For example, a 2GB limit:

private static final long MAX_TOTAL_SIZE = 2L * 1024 * 1024 * 1024;

During extraction, each read chunk increments a counter; if the total exceeds the limit, extraction aborts immediately:

public void copyEntry(ZipInputStream zis, OutputStream out, ExtractionCounter counter) throws IOException {
    byte[] buffer = new byte[8192];
    int len;
    while ((len = zis.read(buffer)) != -1) {
        counter.add(len);
        if (counter.totalBytes() > MAX_TOTAL_SIZE) {
            throw new ZipLimitExceededException("Total uncompressed size exceeds limit");
        }
        out.write(buffer, 0, len);
    }
}

This controls the Uncompressed Size , not the Compressed Size, regardless of whether the ZIP is 20MB, 80MB, or 100MB.

Second Limit: Per-File Size

Even with a total limit, an attacker could include a single 1.9GB file within a 2GB total allowance. If the business only accepts PDFs up to 50MB, such a file should be rejected early. A per-entry limit is added:

private static final long MAX_ENTRY_SIZE = 100L * 1024 * 1024;

Each entry is tracked separately:

long entryBytes = 0;
while ((len = zis.read(buffer)) != -1) {
    entryBytes += len;
    totalBytes += len;
    if (entryBytes > MAX_ENTRY_SIZE) {
        throw new ZipLimitExceededException("Single file exceeds 100MB");
    }
    if (totalBytes > MAX_TOTAL_SIZE) {
        throw new ZipLimitExceededException("Total uncompressed size exceeds limit");
    }
    out.write(buffer, 0, len);
}

Now three distinct limits address different risks:

Max ZIP size: 200MB (upload layer)

Max single file: 100MB (business rule)

Max total uncompressed: 2GB (resource protection)

Third Limit: Entry Count

A ZIP may not be huge but contain hundreds of thousands of tiny files (e.g., 300,000 files of 1KB each = ~300MB total). Each file requires filesystem operations: create path, inode, open, write, close. Subsequent business logic (scanning, parsing, uploading, DB writes) multiplies the cost. An entry count limit is essential: private static final int MAX_ENTRIES = 5000; Increment a counter on each getNextEntry() and abort if exceeded. Many implementations limit size but forget count — both consume server resources.

Fourth Limit: Compression Ratio

As an early warning signal, monitor the compression ratio per entry. For example, a 20KB compressed entry expanding to 500MB is suspicious. Define a max ratio (e.g., 100x):

double ratio = (double) uncompressedSize / compressedSize;
if (ratio > 100) { throw ...; }

However, ZipInputStream reads Local File Headers and may not have reliable getSize() or getCompressedSize() metadata (JDK docs note it doesn't read Central Directory). The most reliable defense remains counting actual bytes read during extraction; compression ratio is only an additional signal.

Path Traversal: Zip Slip

The original path resolution targetDir.resolve(entry.getName()) is vulnerable to directory traversal. If an entry name is ../../../../tmp/test.txt, resolve() does not verify the result stays within the target directory. This is the classic Zip Slip vulnerability (OWASP highlights it). An extraction target of /data/import/10086/ with entry ../../tmp/attack.txt could write to /tmp/attack.txt.

Normalize and Validate Paths

Before creating any file, normalize the root and target paths, then verify the target starts with the root:

private Path safeResolve(Path root, String entryName) {
    Path normalizedRoot = root.toAbsolutePath().normalize();
    Path target = normalizedRoot.resolve(entryName).normalize();
    if (!target.startsWith(normalizedRoot)) {
        throw new InvalidZipEntryException(entryName);
    }
    return target;
}

This allows legitimate nested paths like images/2026/a.jpg but rejects ../../../../etc/xxx. This check should be standard for any user-supplied archive extraction.

Validate Entry Names Beyond Path Safety

Even if a path stays within the root, the entry name may violate business rules: excessive depth (e.g., a/b/c/../../x.pdf), excessive length, disallowed characters, hidden files, empty files. Define business validation:

private void validateEntry(ZipEntry entry) {
    String name = entry.getName();
    if (name.length() > 255) {
        throw new InvalidZipEntryException("Filename too long");
    }
    if (!isAllowedExtension(name)) {
        throw new InvalidZipEntryException("Unsupported file type");
    }
}

ZIP packaging doesn't make files trustworthy; apply the same validation as for individual uploads.

Avoid readAllBytes()

Some code uses zis.readAllBytes() for simplicity, but JDK docs state it's unsuitable for large data and may throw OutOfMemoryError when the required array cannot be allocated. This turns a disk-exhaustion ZIP bomb into a heap-exhaustion one. Use a fixed buffer (e.g., 8KB) to read, count, validate, and write incrementally — keeping memory stable and enabling immediate abort on limit breach.

Don't Rely Solely on ZipEntry.getSize()

Checking entry.getSize() > MAX_ENTRY_SIZE can be a fast pre-check, but metadata comes from the user-controlled archive and may be unreliable or unavailable with ZipInputStream. The ultimate safety boundary must be the actual bytes read during extraction. Metadata helps early rejection; actual byte counting provides the final guarantee.

Disallow Nested Archives

The business allowed PDFs, images, JSON — but not nested ZIPs. Recursive extraction (A.zip → B.zip → C.zip ...) complicates resource control and amplifies ZIP bombs (OWASP notes multi-layer compression increases damage). If nesting isn't needed, simply reject any archive entry:

if (isArchive(entry.getName())) {
    throw new InvalidZipEntryException("Nested archives not allowed");
}

This is simpler than managing recursive depth, per-layer limits, and cross-layer totals. Secure design often means refusing unnecessary capabilities rather than supporting more.

Isolate Extraction Workspace

Don't extract into application directories (e.g., /opt/app/) or the current working directory. A path validation bug could then corrupt critical files. Assign each import a unique, server-generated workspace:

Path taskDir = importWorkRoot.resolve(taskId.toString());
Files.createDirectories(taskDir);

The entire ZIP operates only within this directory. On success: upload valid files to object storage, save business results, delete workspace. On failure: cleanup workflow runs. This isolates tasks from each other.

Cleanup in finally and Beyond

The common bug isn't forgetting cleanup after success — it's exceptions mid-process (e.g., at file 300) skipping the final deleteTemp(). Wrap the whole task in try-finally:

Path workDir = createWorkDir();
try {
    unzip(zipFile, workDir);
    process(workDir);
} finally {
    deleteRecursively(workDir);
}

If the process is killed, finally won't run. Add a background sweeper that deletes only workspaces of tasks marked FAILED or COMPLETED older than 24 hours — never a blind rm -rf that might delete active tasks. Bind cleanup to task state.

Async Extraction, Not Synchronous HTTP

Synchronous extraction in a @PostMapping blocks the web thread for minutes, risks gateway timeouts, and concurrent large imports saturate threads and disk I/O. Instead:

Upload ZIP → save raw file → create ImportTask → return taskId immediately.

Background worker processes states:

PENDING → EXTRACTING → VALIDATING → IMPORTING → COMPLETED

or FAILED.

Resource limits become part of the task, producing user-friendly error codes:

ZIP_UNCOMPRESSED_SIZE_EXCEEDED
ZIP_ENTRY_COUNT_EXCEEDED
ZIP_INVALID_PATH

The UI can then show: "Uncompressed size exceeds 2GB, please split and re-upload" instead of a generic 500 error.

Concurrency Limits at Task Level

Even with per-task limits (e.g., 2GB max uncompressed), 20 concurrent tasks could demand 40GB workspace. Calculate: single-task max × max concurrent tasks. If the work disk has 10GB safe capacity, allow only 3 concurrent extractions; others stay PENDING. True resource protection requires limiting both per-request and aggregate concurrency.

Final Secure Extractor Structure

The hardened extractor incorporates all boundaries:

public void safeUnzip(InputStream input, Path targetDir) throws IOException {
    Path root = targetDir.toAbsolutePath().normalize();
    long totalBytes = 0;
    int entryCount = 0;
    try (ZipInputStream zis = new ZipInputStream(input)) {
        ZipEntry entry;
        while ((entry = zis.getNextEntry()) != null) {
            entryCount++;
            if (entryCount > MAX_ENTRIES) {
                throw new ZipLimitExceededException("File count exceeds limit");
            }
            Path target = root.resolve(entry.getName()).normalize();
            if (!target.startsWith(root)) {
                throw new InvalidZipEntryException(entry.getName());
            }
            validateEntry(entry);
            if (entry.isDirectory()) {
                Files.createDirectories(target);
                continue;
            }
            Files.createDirectories(target.getParent());
            long entryBytes = 0;
            try (OutputStream out = Files.newOutputStream(target)) {
                byte[] buffer = new byte[8192];
                int len;
                while ((len = zis.read(buffer)) != -1) {
                    entryBytes += len;
                    totalBytes += len;
                    if (entryBytes > MAX_ENTRY_SIZE) {
                        throw new ZipLimitExceededException("Single file too large");
                    }
                    if (totalBytes > MAX_TOTAL_SIZE) {
                        throw new ZipLimitExceededException("Total uncompressed size exceeds limit");
                    }
                    out.write(buffer, 0, len);
                }
            }
            zis.closeEntry();
        }
    }
}

Core boundaries enforced:

Target path cannot escape workspace

File count limited

Per-file size limited

Total uncompressed size limited

No full read into memory

This adds little core logic over Files.copy(zis, target) but completely changes the resource safety profile.

The Real Danger Isn't the ZIP Format

ZIP is just a tool. The root cause was treating a user-uploaded archive as merely "an 80MB file" when the server actually processes:

80MB compressed data

Unknown number of entries

Unknown uncompressed size

User-controlled filenames

User-controlled directory structure

These dimensions are completely different. When designing "upload ZIP for batch import", don't just ask "max upload MB?" Also ask:

Max uncompressed GB allowed?
Max number of files?
Max single file size?
Allowed directory depth?
Are nested ZIPs allowed?
Workspace disk capacity?
Max concurrent extraction tasks?
Who cleans up failed task temp directories?

These questions determine whether the interface will survive production. An 80MB ZIP filling a 20GB disk seems absurd until you understand compression — the server consumes uncompressed resources, while the user controls the amplification. Any user-controlled input that gets amplified by the server must have boundaries on the amplified result.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

resource exhaustionSpring BootJava securitydirectory traversalZIP bombZip Slipcompression ratiosecure file upload
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.