Spring4Shell — class loader shenanigans, defused
Spring's data binding let attackers write a webshell through the class loader. Bump Spring, and tighten up the runtime.
01 The Big PictureFor leadership · no jargon
Okay, so this one's about Spring4Shell — CVE-2022-22965 — and how to actually get it fixed, not just read about it. Spring is the most widely used framework for building business software in Java: the plumbing inside an enormous share of customer portals, payment backends, and internal tools. Think of it as the mailroom of an application — it takes incoming web requests and files every form field into the right place. Spring4Shell is a flaw in that mailroom. An attacker can slip extra, invisible "fields" into an ordinary web form — fields that were never on the form — and trick the application into reconfiguring part of its own machinery. The publicly released attack uses that trick to make the server write a small booby-trapped file into its own web directory. Open that file and the attacker has a remote control panel on the server. Yikes.
No username or password is needed. Anyone who can reach the affected page over the internet can run commands as the application: copy out the customer database, deploy ransomware, mine cryptocurrency, or use your servers as a launchpad against partners and customers — followed by the downtime, disclosure obligations, and incident-response bills that come with any breach. The vulnerability leaked before the fix was announced, proof-of-concept code was public the same day, and mass scanning and exploitation began within days. It carries a CVSS score of 9.8 out of 10 and sits on CISA's Known Exploited Vulnerabilities list. Treat it as an emergency on any system that matches the profile, not as a routine patch.
Now for the bit that saved a lot of organisations: the known exploit only works against applications packaged in a specific older style — a WAR file deployed to a standalone Apache Tomcat server, running on Java 9 or newer. The far more common modern setup, a self-contained Spring Boot jar, is not vulnerable to the published attack. The underlying bug is broader than that one attack, tho, so the correct answer is to patch regardless of packaging.
Anyone running Spring Framework 5.3.0–5.3.17 or 5.2.0–5.2.19 needs to act, and honestly should have acted in the first week. The fix itself is unglamorous work: bump a library version, rebuild the application, redeploy it — a rolling restart with no data migration and no expected downtime, minutes per service. Where a rebuild genuinely cannot happen this week, a filtering rule at the web firewall blocks the malicious fields at the door as a stopgap.
02 Technical BreakdownFor engineers
So how does it actually work? Spring's data binding maps HTTP request parameters onto Java bean properties — a form field name=foo becomes bean.setName("foo"), including nested paths like address.street. Since CVE-2010-1622, Spring blocked paths that walk from a bean into its Class object and from there into the class loader. Then JDK 9 broke that shield: every Class now exposes getModule(), so the path class.module.classLoader strolled straight past the old guard and handed the attacker a live reference to the servlet container's class loader. CachedIntrospectionResults happily resolved the whole chain. (A fix from 2010, undone by a JDK release in 2017, noticed in 2022. Such is software.)
The published exploit weaponises that chain on Apache Tomcat. It binds class.module.classLoader.resources.context.parent.pipeline.first.* — properties of Tomcat's AccessLogValve — and sets directory to the web root, suffix to .jsp, a harmless-looking prefix, and a pattern containing JSP code. The valve then "logs" a JSP webshell into webapps/ROOT, which is, frankly, a pretty neat (read: horrifying) trick. Preconditions per the Spring advisory: JDK 9+, the app packaged as a traditional WAR on a standalone servlet container (Tomcat 10.0.19 / 9.0.61 / 8.5.77 and earlier confirmed; Payara and GlassFish also affected), spring-webmvc or spring-webflux on the classpath, and a controller method with a data-bound POJO parameter (@ModelAttribute, or an unannotated complex type). JSON @RequestBody endpoints are not the vector.
Spring Framework 5.3.18 and 5.2.20 fix the root cause: introspection through Class now exposes only getName(), so no request parameter can reach the module or class loader at all. Spring Boot 2.6.6 and 2.5.12 pull in 5.3.18 for managed builds. On the container side, Tomcat 10.0.20, 9.0.62, and 8.5.78 close the valve vector, and downgrading to Java 8 removes the class.module path entirely — both viable stopgaps, neither a substitute for the Spring upgrade.
03 Affected
Spring Framework 5.3.0 through 5.3.17 and 5.2.0 through 5.2.19; older, unsupported versions should be assumed vulnerable. The application must run on JDK 9 or higher, ship as a WAR to a standalone servlet container (Tomcat is the confirmed target), and depend on spring-webmvc or spring-webflux with at least one data-bound controller parameter.
Spring Boot executable jars with an embedded container are not exploitable by the published PoC — but the advisory is explicit that the weakness is more general. Patch every Spring app on the affected framework versions, whatever the packaging.
04 Detection
Right, let's find out what we're running. First, locate the Spring jars themselves:
find / -path '*WEB-INF/lib*' -name 'spring-*.jar' 2>/dev/nullLocate bundled Spring jars in exploded WARs; read the versions straight off the filenames.
grep -R 'spring-web' pom.xml build.gradle 2>/dev/nullConfirm the pinned version in the build.
mvn dependency:tree -Dincludes=org.springframeworkShow the Spring versions Maven actually resolves; transitive pins can override what the pom suggests.
java -versionJDK 9 or higher is a precondition — a Java 8 runtime is not exploitable via this vector.
And if you want to know whether someone's already had a go at you:
grep -E 'class\.(module\.)?classLoader' /var/log/tomcat*/localhost_access_log*.txtHunt Tomcat access logs for exploit parameters; any hit means the box was attacked — treat it as an incident.
05 Remediation
01 Pin the patched Spring version
The real fix is a one-line dependency bump: 5.3.x goes to 5.3.18, 5.2.x to 5.2.20. On Spring Boot, bump the Boot version to 2.6.6 or 2.5.12 instead and let it manage Spring for you.
<spring-framework.version>5.3.18</spring-framework.version>Pin ≥ 5.3.18 (or 5.2.20) in your pom/gradle, then rebuild.
02 Rebuild, test, and redeploy
Rebuild the artifact with tests enabled so binding regressions surface before production (not after — ask me how I know), then roll it out the same way you deploy anything else.
mvn clean package -DskipTests=falseRebuild and re-run tests before shipping.
kubectl rollout restart deploy/appRecycle the pods onto the rebuilt image with zero downtime.
03 Block the malicious parameters at the edge
A WAF deny rule stops the attack before it reaches the app — deployable in minutes, no rebuild required. Keep it in place permanently as defence-in-depth, even after patching.
SecRule ARGS_NAMES "@rx (^|\.)(class|Class)\." "id:229650,phase:2,deny,status:403,msg:'Spring4Shell class binding blocked'" ModSecurity rule rejecting any request parameter that binds into class.* — the exact shape of the exploit.
04 Deny the fields inside the app (bridge only)
Can't bump Spring today? The official advisory workaround is a global binder denylist. It requires a rebuild, and a controller with its own local @InitBinder silently overrides it — a tactical shield, not a fix.
@ControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class BinderControllerAdvice {
@InitBinder
public void setAllowedFields(WebDataBinder dataBinder) {
String[] denylist = new String[]{"class.*", "Class.*", "*.class.*", "*.Class.*"};
dataBinder.setDisallowedFields(denylist);
}
}Verbatim from the Spring advisory; closes binding into the class loader app-wide.
05 Close the container-side vector
Running WARs on Tomcat and unable to touch the app at all? Tomcat 10.0.20 / 9.0.62 / 8.5.78 harden the logging valve the exploit abuses, and downgrading to Java 8 removes the vulnerable path. Both are stopgaps behind a real Spring upgrade.
FROM tomcat:9.0.62-jdk17Bump the base image past the fixed Tomcat line and redeploy.
06 Verification
Patched? Good. Let's prove it:
unzip -p app.jar BOOT-INF/lib/spring-core-*.jar >/dev/null && jar tf app.jar | grep spring-core Confirm the rebuilt artifact bundles the patched Spring; expect spring-core-5.3.18.jar (or 5.2.20).
kubectl rollout status deploy/app --timeout=5mBlock until the rollout reports successful — every pod on the new revision.
curl -s -o /dev/null -w '%{http_code}' -X POST 'https://app/api/endpoint' --data-urlencode 'class.module.classLoader.resources.context.parent.pipeline.first.pattern=pwn'Replay the exploit shape against a bound endpoint; patched Spring refuses the bind — expect a 4xx/ignored and nothing written to disk.
find $CATALINA_BASE/webapps -name '*.jsp' -newermt '2022-03-29' 2>/dev/nullThe PoC drops its webshell under the web root; any JSP you did not deploy is a compromise, not a false positive.
curl -s -o /dev/null -w '%{http_code}' 'https://app/api/health'Smoke the endpoint; expect 200.
07 Rollback
The change is a library bump with no schema or data migration, so rollback is simply redeploying the previous artifact. The edge rule and binder denylist are independent of the app version and can be removed at any time.
kubectl rollout undo deploy/appRoll the previous Deployment if the rebuilt artifact fails health checks.
kubectl rollout status deploy/app --timeout=5mConfirm the previous revision is live and healthy.
One caveat: if Detection found exploit requests or a stray JSP, don't just roll back and call it a day — the host may already be compromised. Re-image it from a known-good source and rotate the application's credentials.
08 References
- NVD — CVE-2022-22965
- Spring — Framework RCE, Early Announcement (official advisory)
- Spring — RCE Mitigation Alternative (Tomcat / Java 8)
- CISA Known Exploited Vulnerabilities catalog
---
Spotted something wrong, or got a question about your setup? Corrections and questions are always welcome — drop me a line :)
Ship the fix. Prove it’s closed.
If this runbook doesn’t fit your stack, send the CVE — an operator writes a verified path, usually within a business day. Or contribute your own and get co-authored credit.