Core Java

Serialization Is Still Java’s Biggest Attack Surface. What JEP 290 Actually Did and What It Didn’t

What JEP 290 actually did, what it genuinely left open, and how to write filters that hold up in the real world — not just on paper.

Java deserialization has been described as “the gift that keeps on giving” — and not as a compliment. Even after Oracle shipped JEP 290 in JDK 9, and backported it to JDK 8u121, new deserialization CVEs keep landing every year. So what exactly did those serialization filters accomplish? And, more importantly, where does the attack surface still sit wide open?

Let’s walk through it carefully, starting from the beginning and working up to the parts that most security write-ups skip entirely.

1. Why Serialization Is Still Relevant and Dangerous

Before anything else, it helps to understand why Java serialization still matters. In short: it is everywhere. Session caches, RMI endpoints, JMX interfaces, message queues, distributed caching layers (think Hazelcast or Ehcache), custom TCP protocols — they all lean on ObjectInputStream. And they have for decades.

The core problem is not serialization itself. As the Red Hat security team correctly pointed out, “Java deserialization is not a vulnerability itself; deserialization of untrusted data is.” The danger comes from treating a byte stream from an untrusted source the same way you’d treat one from a source you control — because the JVM, without protection, will happily reconstruct whatever object graph is in that stream.

Furthermore, the numbers back this up. As of October 2023, the National Vulnerability Database had over 1,067 entries matching CWE-502 (Deserialization of Untrusted Data). A 2023 ACM study found 364 CVEs linked to deserialization vulnerabilities across mainstream languages in the previous decade, with Java accounting for a disproportionate share. And new ones keep appearing — a critical 9.8 CVSS deserialization CVE in Jaspersoft Library was disclosed as recently as 2025.

Java Deserialization CVEs over time (CWE-502)

Cumulative NVD entries for deserialization vulnerabilities, showing the problem has not plateaued post-JEP 290.

2. What JEP 290 Actually Did

Before JEP 290 landed, the Java deserialization framework had no built-in validation mechanism at all. Once your code called readObject(), whatever was in the stream got reconstructed — no questions asked. JEP 290 changed that by introducing a filter hook that sits between the stream and object construction.

The core mechanism, as described in the JEP itself, is an ObjectInputFilter interface. You implement that interface and attach it to an ObjectInputStream. The JVM then calls your filter before each object is instantiated, giving you the chance to accept, reject, or defer the decision. Importantly, the filter is called for each class referenced in the stream — before the object is constructed, which is exactly the right moment to intervene.

Three layers of filtering

JEP 290 gives you three places to plug in a filter, and understanding the difference matters a great deal in practice.

Filter LayerScopeHow to Set ItWhen to Use It
JVM-wide (global)Every ObjectInputStream in the JVM-Djdk.serialFilter=... or conf/security/java.securityBaseline deny-list across the whole app
Stream-specificOne specific ObjectInputStream instancestream.setObjectInputFilter(...)Tight allow-list around known deserialization points
Filter factory (JEP 415, JDK 17+)Per-stream, dynamically composedObjectInputFilter.Config.setSerialFilterFactory(...)Complex apps with multiple execution contexts

Additionally, JEP 290 lets you enforce resource limits — not just class names. These four numerical guardrails help you prevent denial-of-service attacks even when class filtering doesn’t block the payload:

Limit ParameterWhat It ControlsRecommended Starting Point
maxdepthMaximum depth of the object graphmaxdepth=10
maxrefsMaximum number of internal referencesmaxrefs=200
maxbytesMaximum bytes consumed from the streammaxbytes=65536
maxarrayMaximum array length permittedmaxarray=1024

This is genuinely valuable. A crafted stream that tries to reconstruct a billion nested objects to cause an OutOfMemoryError — a classic deserialization DoS — can be stopped cold by a reasonable maxdepth and maxrefs value.

3. How to Write a Correct Filter for Your Application

Most guides stop at “just add a deny-list.” That, however, is the wrong approach for any serious application. Here is why — and what to do instead.

Deny-listing is a losing game

A deny-list (also called a blacklist) works by naming classes you want to block. The problem is that you have to know which classes are dangerous before someone exploits them. New gadget chains surface constantly. As Shai Almog’s analysis puts it: “the list is not exhaustive, and there is no canonical source for a proper blacklist.” Deny-listing is essentially fighting the last war.

Important: Oracle’s documentation notes that a serialization filter is not enabled or configured by default. Filtering does not occur unless you explicitly configure it. Many applications are running completely unprotected without realising it.

Allow-listing is the correct posture

An allow-list (whitelist) flips the model: you block everything, then explicitly permit only the classes your application actually expects. This is the approach that holds up. The pattern syntax uses a semicolon as a separator, and a trailing !* rejects everything not previously matched.

To apply a JVM-wide allow-list as a command-line property, the pattern looks like this:

# Allow your own domain, reject everything else
java "-Djdk.serialFilter=com.mycompany.model.*;java.util.*;!*" -jar MyApp.jar

To make it permanent across restarts without changing code, add it to the security properties file at $JAVA_HOME/conf/security/java.security:

# In $JAVA_HOME/conf/security/java.security
jdk.serialFilter=com.mycompany.model.*;java.util.ArrayList;java.util.HashMap;maxdepth=10;maxrefs=200;maxbytes=65536;maxarray=1024;!*

For stream-specific filtering — the tightest and most recommended approach — you set the filter directly on the stream in code. With JDK 17+, you can also chain filters via the filter factory introduced in JEP 415 to compose context-specific policies without touching every deserialization call site.

Best practice: Always combine class-name allow-listing with numerical limits. A filter that only checks class names gives an attacker room to attempt denial-of-service through deeply nested graphs. Adding maxdepth and maxrefs closes that gap.

Pattern rules you must know

The JEP 290 pattern syntax has some non-obvious behaviour that trips developers up. Oracle’s documentation is clear on the following critical limitations:

LimitationPractical Implication
Patterns do not match on supertype or interfacesAllowing java.util.Collection does NOT implicitly allow ArrayList; you must list each concrete class
Filters are not called for primitives or inline StringsAn attacker can pass arbitrary string data through this gap — it is not as dangerous as RCE gadgets, but worth knowing
Pattern-based filters have no stateYou cannot write logic like “allow at most 3 HashMap instances” without implementing the full ObjectInputFilter interface in code
Whitespace in the pattern string is significantA stray space inside the filter string breaks matching silently — double-check your configuration
Limits are checked before class patternsResource limits always apply first, regardless of their position in the pattern string

4. What JEP 290 Did Not Fix

Here is where things get interesting — and where most post-2016 articles trail off. JEP 290 is a mechanism, not a policy. It gives developers the tools to defend themselves, but it does not configure those tools for you, it does not audit your existing code, and it does not protect deserialization paths that bypass ObjectInputStream entirely.

1. It is opt-in, and most applications never opted in

A 2024 NDSS study found that on GitHub, over 3,600 Java projects with known deserialization exposure still lacked a strict filter policy. The filter exists but it is not applied. This is arguably the biggest failure mode — not a gap in the mechanism itself, but a deployment gap that years of documentation have not closed.

2. Non-standard deserialization frameworks are unprotected

JEP 290 filters apply to ObjectInputStream. But Java applications regularly deserialize data through other mechanisms that have nothing to do with that class. JSON libraries (Jackson, Gson), YAML parsers (SnakeYAML), XML parsers, Kryo, and Hessian all have their own deserialization paths. A perfectly configured jdk.serialFilter does absolutely nothing to protect any of these. Each one requires its own hardening strategy.

The IBM ORB deserialization incident (CVE-2022-40609) is a stark example. IBM’s Object Request Broker did not honour JEP 290 filters during deserialization, meaning every filter that developers had configured was silently bypassed, leaving the JVM open to gadget-based RCE.

3. It does not remove dangerous classes from the classpath

Even a correctly configured deny-list does not stop a determined attacker who finds a gadget chain composed of classes that your allow-list permits. The attack surface reduction only works as well as the list you write. If your application needs Apache Commons Collections on the classpath, you cannot simply block it — and if that library version contains gadget-eligible classes, those remain available to a serialization payload that slips through.

4. Filters have no look-ahead capability

This is a design limitation that the JEP itself acknowledges. Filters cannot inspect the contents of an object after it is reconstructed, nor can they see the full graph structure before individual objects are instantiated. A filter can say “block class X” but cannot say “block any object graph that would result in a call to Runtime.exec().” That kind of semantic analysis does not exist in the filter interface.

JEP 290 Protection Coverage by Attack Category

Where the filter mechanism provides real protection vs. where gaps remain. Estimates based on documented limitations in JEP 290, JEP 415, and published academic analysis.

5. Gadget Chains That Remain Viable Today

A gadget chain is a sequence of method calls — chained together through existing classes in your application’s classpath — that leads from a deserialization entry point to a security-sensitive operation like arbitrary code execution. Think of each class as a gear; individually harmless, but connected in the right order, they drive something dangerous.

The tool most associated with gadget chain research is ysoserial, which collects known payloads for libraries like Apache Commons Collections, Commons BeanUtils, Spring, and Groovy. However, as a 2025 academic paper from the ACM CCS conference observed, ysoserial’s last gadget chain was added in February 2021 — meaning it increasingly reflects the past, not the present.

What remains viable despite JEP 290?

CommonsBeanutils1 — still alive on many stacks

A real-world 2024 penetration test by Praetorian against Relution found that CommonsBeanutils1 was present on the classpath. The testers generated a ysoserial payload, sent it over a JGroups message channel, and received a reverse shell. The JEP 290 filter was either absent or not configured to block that class. This is representative of how these chains succeed in the wild — not because the filter is broken, but because it was never applied.

Application-specific chains from lesser-known libraries

Research published in 2025 introduced the concept of “dormant” gadget chains — chains that do not exist in a library today, but can be activated by small code changes (adding a Serializable interface to a class, changing a method signature). The implication is significant: even if you scan your classpath today with a tool like GadgetInspector and find nothing, a routine dependency update could silently introduce a new chain. Static analysis at a point in time is not a substitute for filtering at runtime.

Chains that survive allow-listing through permitted classes

If your application legitimately deserializes classes from a library that also happens to contain gadget-eligible classes, your allow-list must permit those classes. At that point, a well-crafted payload that uses only those permitted classes can still execute. The 2024 Praetorian case illustrated this exact scenario: the application needed the library, the library contained the gadget, and the filter could not distinguish legitimate use from attack use.

Gadget / ChainLibrary RequiredRCE Possible?Filter Effectiveness
CommonsCollections1–7commons-collections < 3.2.2YesBlocks if library is deny-listed or absent from allow-list
CommonsBeanutils1commons-beanutils, commons-collectionsYesBlocks if deny-listed; bypasses if app needs the library
Spring1 / Spring2Spring Framework < 4.xYesOlder Spring versions; updated stacks largely unaffected
FileUpload1commons-fileupload 1.3.1, commons-io 2.4File writeStill worked in 2022 testing on unpatched versions
AspectJWeaverAspectJ + Commons Collections 4File writeDiscovered in 2024 Praetorian research; allow-list bypass risk
Novel / custom chainsApp-specific dependency setPotentiallyNot covered — require runtime tools or agent-level protection

Key finding: The 2024 ACM analysis of 46 ysoserial gadget chains found that gadget chains are present in recent versions of libraries and JDK releases — not just old ones. Updating dependencies does reduce risk, but it does not eliminate it.

What actually helps beyond JEP 290

Given these remaining gaps, defence-in-depth matters more than any single mechanism. Concretely, that means combining JEP 290 filters with the following:

  • Runtime agents like NotSoSerial that block deserialization at the bytecode level regardless of the deserialization framework.
  • Dependency hygiene — removing library versions that contain known gadget-eligible classes whenever a safe alternative exists.
  • Eliminating ObjectInputStream use entirely where you control both ends of the wire. Modern formats like Protocol Buffers, JSON with schema validation, or Records with explicit parsing have no implicit gadget attack surface.
  • Network-level controls so that untrusted sources cannot reach serialization endpoints at all.

6. What We Have Learned

Java serialization remains an active attack surface in 2026, despite JEP 290 representing a genuine and meaningful hardening step. The filter mechanism works — when it is actually configured. That is the central tension. It gives developers a precise tool to restrict which classes can be deserialized and how complex the object graph can grow, preventing both known gadget-chain exploitation and denial-of-service attacks through graph inflation.

However, filters are opt-in, the vast majority of production applications never explicitly enable them, and even well-written filters only protect ObjectInputStream-based paths — leaving JSON, YAML, and custom serialization frameworks entirely out of scope. Furthermore, the threat is not static. Novel gadget chains emerge as dependencies evolve, and application-specific allow-lists can inadvertently permit chains assembled from classes the application legitimately needs. The correct posture today is to apply tight allow-list filters with numerical limits, eliminate native Java serialization wherever possible, use runtime agents for additional coverage, and treat any external data source reaching a deserialization endpoint as inherently adversarial.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button