Dev.to Security 🔐 Cybersecurity 👁 0 📖 3 min read

Zip Slip and decompression bombs in Java: how to extract safely

Extracting an archive you did not create is a security boundary. Two attacks cross it with a few lines of crafted input: Zip Slip writes outside the target directory, and a decompression bomb fills your disk from a tiny

Extracting an archive you did not create is a security boundary. Two attacks cross it with a few lines of crafted input: Zip Slip writes outside the target directory, and a decompression bomb fills your disk from a tiny file. This post reproduces both against the JDK and shows the fix. Every snippet ran against Compress4J 5.0.0 on Java 21.

Zip Slip

Zip Slip, disclosed by Snyk in 2018, abuses entry names like ../../pwned.txt. Code that joins the entry name onto the output directory writes wherever the name points:

static void naive(Path zip, Path out) throws IOException {
    try (ZipInputStream in = new ZipInputStream(Files.newInputStream(zip))) {
        for (ZipEntry e; (e = in.getNextEntry()) != null; ) {
            Path target = out.resolve(e.getName());
            Files.createDirectories(target.getParent());
            Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }
}

Build an archive with one entry named ../../pwned.txt and extract it into the directory a/b/naive. The entry resolves to a/b/naive/../../pwned.txt, so the file lands in a/pwned.txt, two levels above the output directory.

The pure-JDK fix is to normalize the resolved path and reject any that leaves the output directory:

Path root = out.toAbsolutePath().normalize();
Path target = root.resolve(e.getName()).normalize();
if (!target.startsWith(root)) {
    throw new SecurityException("Zip Slip detected: " + e.getName());
}

This stops ../ names. It does not stop symlinks, which the next section covers. Compress4J does both checks for every format:

try (ZipArchiveExtractor extractor = ZipArchiveExtractor.builder(zip).build()) {
    extractor.extract(out);
} catch (UnsafeEntryException e) {
    // Path traversal vulnerability detected! Entry: .../a/b/safe/../../pwned.txt
    // is outside of target directory: .../a/b/safe
}

Nothing is written outside out.

Symlinks

A name check is not enough. A tar can first create a symlink link -> /etc, then write link/passwd-copy, which lands in /etc while every entry name looks clean. Compress4J rejects absolute and escaping symlink targets by default:

try (TarArchiveExtractor extractor = TarArchiveExtractor.builder(tar).build()) {
    extractor.extract(out);
} catch (UnsafeEntryException e) {
    // Invalid symlink (absolute path): link -> /etc
}

escapingSymlinkPolicy changes this. RELATIVIZE_ABSOLUTE rewrites absolute targets under the output directory. ALLOW extracts links unchecked, which is only right for archives you produced yourself.

Decompression bombs

Deflate compresses runs of identical bytes by about 1000:1. A 200 MiB file of zeros fits in a 204 KB zip. Extract a few thousand of those and the disk is full.

Compress4J starts from ExtractionLimits.defaults(): 1,000,000 entries, an expansion ratio of 100 once the reader has produced 1 MiB, and no size caps. The bomb above stops at the ratio check:

try (ZipArchiveExtractor extractor = ZipArchiveExtractor.builder(bomb).build()) {
    extractor.extract(out);
} catch (LimitExceededException e) {
    // limit=RATIO max=100 entry=zeros.bin
    // "Entry 'zeros.bin' exceeded the expansion ratio limit of 100 (raise it with maxRatio)"
}

About 1 MiB of the entry is already on disk when the check fires. Entries written before a breach stay, so treat the exception as a failed extraction and delete the output directory.

Add a hard size cap for input from untrusted sources:

ExtractionLimits policy = ExtractionLimits.defaults()
        .withMaxTotalSize(100L << 20) // 100 MiB
        .withMaxRatio(10_000);
try (ZipArchiveExtractor extractor = ZipArchiveExtractor.builder(bomb).limits(policy).build()) {
    extractor.extract(out);
} catch (LimitExceededException e) {
    // limit=TOTAL_SIZE max=104857600
}

Tuning the limits

The ratio of 100 suits typical data. Highly repetitive data such as log archives can exceed it; when you trust that data, raise maxRatio. Use ExtractionLimits.noLimits() only for archives you built yourself.

UnsafeEntryException and LimitExceededException both extend UnsafeInputException, so one catch covers every guard. extract stops at the first breach and throws. The builder's errorHandler can skip entries that fail with an ordinary IOException, but it never receives an UnsafeInputException, so skipping failures cannot keep feeding a bomb to the extractor.

Try it

Gradle:

implementation("com.hominux:compress4j:5.0.0")

Maven:

<dependency>
    <groupId>com.hominux</groupId>
    <artifactId>compress4j</artifactId>
    <version>5.0.0</version>
</dependency>
📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.