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 landed outside the destination directory. That flaw sat in every release since 2015, and the maintainer has now fixed it. What follows 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 is made of stylesheets that turn an XML representation of the HTTP request into an XML representation of the response. On top of that, the framework ships a library of XPath and XQuery extension functions, for HTTP calls, database access, file access, and extracting 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 plenty of 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 problem is a simple one. A ZIP file may carry an entry name like ../../../etc/cron.d/evil. If the extracting code blindly sticks that name onto a destination directory, the file doesn't land inside that directory. It lands wherever the extracting process happens to have write access.

Eight years on from that research, the same mistake still turns up, and xslweb is the most recent 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. The function also happily takes an http(s):// URL, in which case xslweb fetches the file itself. $target is the directory the contents land in. The actual extraction logic lives in ZipUtils.unzipStream(), and before the fix it looked like this:

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. 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 batch of images:

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

The developer trusts xslweb:unzip() to handle that safely. That's the whole reason you reach for a framework function instead of building one yourself. But this function did no validation at all. Every xslweb application that used it to extract visitor-supplied ZIP content picked up the vulnerability for free. Not through a developer's mistake, but through a building block the framework itself shipped broken.

One more thing. $source also accepts a URL that the server fetches itself. A stylesheet that passes a request-controlled parameter into xslweb:unzip() unfiltered pairs Zip Slip with SSRF. The server pulls 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. Drop a JSP file into Tomcat's webapps directory and you usually get remote code execution. And even short of that, arbitrary file-write access almost always opens a path to more, whether that's overwriting configuration files, replacing stylesheets, or tampering with logs.

Vulnerabilities like this are not found by a scanner, but by examining the code and its behaviour with an attacker's eye. That is exactly what a manual pentest does.

Schedule a free intake →

The fix

The maintainer of xslweb merged a rewritten unzipStream() on 26 May 2026 (commit 518c9ed). The core of the fix is a single 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 strips out any ../ segments. Only then comes the check: does the result still fall under the destination directory? If not, the function throws instead of writing. The rest of the implementation moved to the java.nio.file APIs, with try-with-resources and a clean error on an empty archive.

Status of this issue

Found and reported by: Sofyan Aarrass (Resync).
Fix: merged on 26 May 2026 by the maintainer (commit 518c9ed).
Published release containing the fix: none yet. The latest tagged release is v4.2.0 (January 2022).

If you run xslweb

Every published release, the current v4.2.0 included, contains the vulnerable code. Nothing has shipped after the fix yet. Build from the latest master branch and you pick up the patch. If you're on a published release and your application extracts visitor-supplied ZIP files via xslweb:unzip(), apply 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, and every published release since then, from v2.0.0 through the current v4.2.0, carries the vulnerable version. Not because nobody looked at the code, but because an unsafe unzip implementation gives nothing away until someone goes looking for it. It compiles, it runs, and anyone using it the normal way never notices. Zip Slip doesn't hide in the rarely used corner of a codebase. It sits in the great bulk 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()) shows up in countless tutorials and Stack Overflow answers, and so, not by chance, in the training data of just about every AI coding assistant. (That ties into something we also describe in vibe-coded apps: code that works gets copied everywhere, while code that's actually secure has to be built on top on purpose.) In practically every language and library, extracting a ZIP is one of those functions that "just works," with no built-in path protection. You add that yourself, or you reach for a library that already does it for you.

How to prevent this yourself

Never trust entry.getName() blindly

Resolve the path against the destination directory, normalise it, and only then check that it still falls inside, before you write anything. That's exactly the pattern in the fix above, and it's three lines that take out 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 reach for 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, those contents are untrusted. No matter 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 why Zip Slip keeps resurfacing years after Snyk's original research. It isn't hard to find. It's just one nobody checks for, until somebody does.

For xslweb the picture is clear: fixed in code, not yet in a release. For the rest of us, it's a good reason to take one critical look at every "extract" function in your own stack.

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 walks right past.

Go to web application pentest →