Spring4Shell: RCE in Spring Framework
Data binding in Spring MVC on JDK 9+ could be abused to reach class-loader properties and write a web shell, giving remote code execution.
- Severity
- 9.8critical
- Vendor
- VMware
- Disclosed
- 31 Mar 2022
- Affected
- Spring Framework 5.3.x, 5.2.x
CVSS breakdown
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- High
- Integrity
- High
- Availability
- High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Analysis
Spring4Shell arrived three months after Log4Shell, inherited the naming convention, and was substantially less bad than the panic suggested. It is a useful case study in conditional exploitability — and in how badly the industry handles it.
#The bug
Spring MVC can bind HTTP request parameters directly onto a Java object. Send name=value and Spring calls setName("value") on your POJO. This is the convenience at the heart of Spring's request handling.
Binding follows nested property paths. user.address.city=X walks the object graph and sets the property at the end. Spring resolves each step by reflection.
Every Java object inherits getClass(). So class.module.classLoader... is a valid property path from any bound object — it walks out of your data object and into the JVM's class loader.
From there the attack targets Tomcat's AccessLogValve, which exposes writable properties for its log file's directory, prefix, suffix and pattern. Set those via the class-loader path and you have turned the access log into an attacker-chosen file at an attacker-chosen path — write a JSP into the webapp root, set the log "pattern" to your payload, and request the file. Web shell.
#Why JDK 9+ matters
This is essentially CVE-2010-1622 returning. Spring fixed that one by blocklisting class.classLoader in the binding path.
JDK 9 introduced the module system, which added Class.getModule(). That created a new route to the same destination — class.module.classLoader — which the old blocklist did not cover. The mitigation was specific to a path rather than to the class of access, and a language change opened a parallel path around it.
That is the most technically interesting thing here: a blocklist that was correct for a decade was silently invalidated by an upstream platform feature.
#The conditions
Unlike Log4Shell, this needed a specific stack:
- JDK 9 or later (JDK 8 lacks
getModule()) - Spring MVC or WebFlux with parameter binding to a non-primitive object
- Deployed as a WAR on Tomcat — the classic exploit path depends on Tomcat's
AccessLogValve - Spring Framework below 5.3.18 / 5.2.20
The default Spring Boot deployment is an executable JAR with embedded Tomcat, which is not exploitable via the published path. Most Spring Boot applications were never vulnerable to the public exploit.
But note the qualifier. The class-loader access is real regardless of packaging; AccessLogValve is one known gadget. "Not exploitable by the public PoC" and "not vulnerable" are different claims, and the honest reading is: patch anyway, deprioritise relative to Log4Shell.
The industry mostly failed at this. The Log4Shell-shaped name triggered a Log4Shell-shaped response, and a lot of emergency change windows were burned on JAR-packaged Boot apps that were never reachable.
#Detection
- Version and stack: Spring Framework < 5.3.18 / 5.2.20, on JDK 9+, packaged as a WAR on Tomcat. All four conditions.
- Access logs for parameter names containing
class.module.classLoaderorclass.classLoader. The exploit must appear in request parameters, so it is greppable — though it can be split across parameters and encoded. - Look for the artefact. Successful exploitation writes a JSP, usually into the webapp root, and often leaves the access log reconfigured — a log file with a bizarre suffix or a
.jspextension is a strong signal. - Unexpected
.jspfiles in deployment directories, timestamped after deployment.
#Fixing it
Upgrade to Spring Framework 5.3.18 or 5.2.20+ (or Spring Boot 2.6.6 / 2.5.12+). The fix restricts property resolution so class cannot be traversed as a binding path.
If you cannot patch immediately, @InitBinder with setDisallowedFields("class.*", "Class.*", "*.class.*", "*.Class.*") blocks the path — but applied per-controller it is easy to miss one, and a blocklist is exactly what failed the first time.
#What generalises
Blocklists age badly. The 2010 fix was correct against the language as it existed. A JDK feature added a synonym and the control silently stopped working, with no signal that anything had changed. Allowlist the properties that are legitimately bindable rather than enumerating the dangerous ones.
Automatic binding of request data onto objects is a dangerous default. Mass assignment is the same bug class across every framework — Rails, Django, ASP.NET. When a framework maps attacker-controlled names onto object properties by reflection, the reachable object graph is the attack surface, and it is far larger than the class you thought you were populating.
And for defenders: resist naming-driven prioritisation. Severity is a property of your deployment, not of the logo.