Skip to content
highCVSS 7.5CWE-22A01:2021 – Broken Access Control

Java

The servlet container normalises the dispatcher path. It does not normalise your File. That gap is where the traversal lives.

Two different resolvers

Java has two inclusion-shaped sinks and they behave differently, which is the source of most of the confusion.

RequestDispatcher.include() / forward() resolves inside the servlet context. Tomcat, Jetty and Undertow all normalise the path and reject .. segments, so this is a constrained inclusion — you can reach resources the container serves, not arbitrary files. Still a finding when it reaches something under WEB-INF, which is meant to be unreachable.

new File(dir, name) and Files.readString(Path.of(...)) do no normalisation at all. Whatever you pass is what gets opened. This is the ordinary traversal sink and it is where the volume is.

There is no stream-wrapper layer, so nothing from the PHP escalation section applies. Java inclusion is a read primitive.

The mistake

Javathe gap between check and openVulnerable
// new File() does not normalise. getPath() returns the string you gave it,
// with the .. still in it — so a check on getPath() inspects a different
// value from the one the filesystem will resolve.
File f = new File(baseDir, request.getParameter("name"));
if (f.getPath().startsWith(baseDir)) {          // passes: "/app/data/../../etc/passwd"
    return Files.readString(f.toPath());        // opens:  /etc/passwd
}

// Same class of bug with NIO. Path.of() does not resolve either.
Path p = Path.of("templates", request.getParameter("page"));
return Files.readString(p);

// And the dispatcher version, which is constrained but not harmless:
request.getRequestDispatcher(request.getParameter("page"))
       .include(request, response);
// The container rejects ../, but /WEB-INF/web.xml is a perfectly valid
// context-relative path — and that is exactly what WEB-INF is supposed
// to be protected from.

The fix

JavaSecure
// A map, as everywhere else. The parameter is a key and never a path.
private static final Map<String, String> PAGES = Map.of(
    "home",    "home.html",
    "about",   "about.html",
    "contact", "contact.html"
);

String key = request.getParameter("page");
String file = PAGES.get(key == null ? "home" : key);
if (file == null) {
    response.sendError(HttpServletResponse.SC_NOT_FOUND);
    return;
}
return Files.readString(BASE.resolve(file));

JSP includes

<jsp:include page="…"> is a runtime dispatcher include, so the container's normalisation applies and it cannot leave the context. What it can do is reach any resource the context contains, including WEB-INF, which is the part developers assume is unreachable.

<%@ include file="…" %> is a compile-time directive. The value is resolved when the JSP is translated, so it cannot be attacker-controlled at request time — this one is not a runtime sink at all, which is worth knowing so you do not spend time on it.

JSTL's <c:import url="…"> is the genuinely dangerous one: it accepts an absolute URL and fetches it, which makes it SSRF. See Remote Include Outside PHP.

A historical note that still matters for older targets: several Tomcat and Spring path-traversal CVEs came from a mismatch between the container's normalisation and a framework's own handling of encoded separators — %2f in particular. Tomcat's ALLOW_ENCODED_SLASH and ALLOW_BACKSLASH options exist because of this class of bug, and both default to off for good reason. If you meet a target where they are on, encoded separators are worth trying.

Zip Slip

Javathe write-side variantVulnerable
// Java's original Zip Slip. The entry name is attacker-controlled and
// resolved without confinement, so an entry called
// ../../../../opt/tomcat/webapps/ROOT/shell.jsp writes a webshell.
while ((entry = zis.getNextEntry()) != null) {
    File out = new File(destDir, entry.getName());
    Files.copy(zis, out.toPath());               // <- no check
}

// Fix: canonicalise each entry and confine it.
File dest = destDir.getCanonicalFile();
while ((entry = zis.getNextEntry()) != null) {
    File out = new File(dest, entry.getName()).getCanonicalFile();
    if (!out.getPath().startsWith(dest.getPath() + File.separator)) {
        throw new IOException("zip slip: " + entry.getName());
    }
    Files.copy(zis, out.toPath());
}

What to grep for

Bash
# Read sinks
grep -rnE 'new File\(|Path\.of\(|Paths\.get\(|Files\.(readString|readAllBytes|newInputStream)' \
  --include='*.java' . | grep -iE 'request|param|query|body'

# Dispatcher includes and forwards
grep -rn 'getRequestDispatcher' --include='*.java' .

# JSP and JSTL
grep -rnE '<jsp:include|<c:import' --include='*.jsp' .

# Spring controllers returning a request-derived view name
grep -rnE '@(Get|Post|Request)Mapping' -A6 --include='*.java' . | grep -B2 'return.*param'

# The correct calls, to confirm they are present
grep -rn 'getCanonicalPath\|toRealPath\|normalize()' --include='*.java' .

# Archive extraction
grep -rn 'ZipInputStream\|ZipFile\|TarArchiveInputStream' --include='*.java' .