Zip Slip in xslweb: a missing path check in a built-in unzip function

The built-in unzip function in the Java XSLT framework xslweb extracted ZIP files without checking whether an entry wrote outside the destination directory. That flaw rode along in every release since 2015 and is now fixed by the maintainer. This is the technical anatomy of the bug, the fix, and why this pattern keeps coming back.

What is xslweb?

xslweb is an open source web framework for developers who'd rather work in XSLT and XQuery than a traditional language. An application consists of stylesheets that transform an XML representation of the HTTP request into an XML representation of the response. The framework also ships a library of XPath and XQuery extension functions: HTTP calls, database access, file access, and a function to extract a ZIP file. That last one, xslweb:unzip($source, $target), is what this article is about.

It's not a household name in the Java world, but xslweb has been maintained since 2015 and runs, like many comparable frameworks, as a WAR file in a servlet container such as Tomcat.

Zip Slip in a nutshell

Zip Slip is a vulnerability class that Snyk first documented widely in 2018, after finding it in dozens of popular Java, JavaScript, Go and .NET projects. The core problem: a ZIP file may contain an entry name like ../../../etc/cron.d/evil. If the code that extracts the archive blindly concatenates that name onto a destination directory, it doesn't write the file inside that directory. It writes it wherever the process performing the extraction happens to have write access.

Eight years after that research, the exact same mistake still turns up, and xslweb is the latest example I ran into.

Where it went wrong

The function

xslweb exposes an extension function to stylesheets, xslweb:unzip($source, $target), defined in Unzip.java. $source can be a local file path or a file: URI. Interestingly, the function also accepts an http(s):// URL: in that case, xslweb fetches the file itself. $target is the directory the contents land in. The actual extraction logic lives in ZipUtils.unzipStream(), which looked like this before the fix:

while ((entry = zis.getNextEntry()) != null) {
  File file = new File(extractTo, entry.getName());
  if (entry.isDirectory()) {
    if (!file.exists()) {
      file.mkdirs();
    }
  } else {
    ...
    BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file));
    ...
  }
}

new File(extractTo, entry.getName()) is the entire vulnerability in one line. entry.getName() comes straight out of the ZIP file and is never normalised or checked anywhere. An entry named ../../../../opt/tomcat/webapps/ROOT/shell.jsp gets written to exactly that path, well outside extractTo.

Why this is exploitable, not just theoretical

The function is meant for developers who want to extract a visitor-uploaded ZIP into a working directory, say a theme package, a content import, or a set of images:

<xsl:value-of select="xslweb:unzip($uploaded-zip-path, $target-dir)"/>

The developer trusts xslweb:unzip() to do that safely. That's exactly what you use a framework function for instead of building it yourself. But the function performed no validation whatsoever. Every xslweb application that used this built-in function to extract visitor-supplied ZIP content inherited the vulnerability automatically: not through a mistake by the developer, but through a flaw in the building block the framework provided.

A second observation: $source also accepts a URL that the server fetches itself. A stylesheet that passes a request-controlled parameter into xslweb:unzip() unfiltered combines Zip Slip with SSRF. The server fetches a ZIP from a location the attacker controls, then extracts it unsafely.

The impact

What exactly an attacker can overwrite depends on the deployment: the write permissions of the servlet container process, the directory layout, which files the application reads back in. But the pattern is familiar: dropping a JSP file into Tomcat's webapps directory typically results in remote code execution. At minimum, arbitrary file-write access almost always provides a path to further escalation, such as overwriting configuration files, replacing stylesheets, or tampering with logs.

The fix

Maarten Kroon, the maintainer of xslweb, merged a rewritten unzipStream() on 26 May 2026 (commit 518c9ed). The core of the fix is a validation step run before every write:

private static Path resolveEntryPath(Path destRoot, ZipEntry entry) throws IOException {
  // Normalize ensures that any ../ segments are removed before validation
  Path resolved = destRoot.resolve(entry.getName()).normalize();
  if (!resolved.startsWith(destRoot)) {
    throw new IOException("Zip Slip detected for entry: " + entry.getName());
  }
  return resolved;
}

Every entry is first resolved against the destination directory and normalised, which removes any ../ segments, and only then checked: does the result still fall under the destination directory? If not, the function throws instead of writing. The rest of the implementation was moved to the java.nio.file APIs, with try-with-resources and an explicit error on an empty archive.

Status of this issue

Found and reported by: Sofyan Aarrass (Resync).
Fix: merged on 26 May 2026 by maintainer Maarten Kroon (commit 518c9ed).
Published release containing the fix: none yet. The latest tagged release is v4.2.0 (January 2022).
CVE: requested 25 June 2026; assignment is still pending at the time of writing. This article will be updated once an ID exists.

If you run xslweb

Every published release, including the current v4.2.0, contains the vulnerable code. No release has shipped after the fix yet. Building from the latest master branch picks up the patch. If you're on a published release and your application extracts visitor-supplied ZIP files via xslweb:unzip(), consider applying the validation from the fix above locally until a new release ships.

A vulnerability that's ridden along for a decade

It's worth pausing on how long this went unnoticed. unzipStream() first appeared in November 2015. I checked: every published release since then, from v2.0.0 through the current v4.2.0, contains the vulnerable version. Not because nobody looked at the code, but because an unsafe unzip implementation doesn't stand out until someone specifically looks for it: it compiles, it works, the happy-path test passes. Zip Slip doesn't live in the minority of code that's rarely used. It lives in the majority of code that's never tested with malicious input.

The broader lesson

Zip Slip isn't an exotic vulnerability. The pattern new File(parent, entry.getName()) appears in countless tutorials and Stack Overflow answers, and not coincidentally in the training data of every AI coding assistant too. (This connects to something we also describe in vibe-coded apps: code that works gets copied everywhere; code that's specifically secure has to be deliberately built on top.) Extracting a ZIP is, in practically every language and library, a function that "just works" with no built-in path protection. You have to add that yourself, or use a library that already does.

How to prevent this yourself

Never trust entry.getName() blindly

Resolve the path against the destination directory, normalise it, and verify containment before writing. That's exactly the pattern in the fix above: three lines of code that eliminate the entire vulnerability class.

Or use a library that already enforces it

Apache Commons Compress and recent versions of zip4j offer safe extraction variants. If you can use those instead of a hand-rolled extraction loop, do.

Treat "extraction" as untrusted input, even when the caller is trusted

The vulnerability wasn't in who could call the function: that was a trusted party, a developer writing a stylesheet. It was in the contents of the file being extracted. Whether that content was uploaded by a user or fetched from a URL, the moment your application processes a ZIP, TAR or similar archive from outside, its contents are untrusted, regardless of how trusted the code calling the extraction is.

Conclusion

An unzip function that's been sitting there for a decade, in a framework still running in production, with a flaw that turns out to be a three-line fix. That's exactly why Zip Slip, years after Snyk's original research, keeps resurfacing: it's not a hard vulnerability to find. It's one nobody checks for until somebody does.

For xslweb: fixed in code, not yet in a release, CVE requested. For the rest of us: a good reason to take a critical look at every "extract" function in your own stack, once.

You won't find this kind of bug with a scanner.

Path validation, authorisation, business logic: exactly the kind of vulnerability a manual pentest uncovers and an automated scan skips.

Go to web application pentest →