Safeguard
Industry Analysis

Insecure Deserialization Prevention in Java with Deserial...

Java deserialization RCEs still hit production years after JEP 290 shipped filters. Here's how JEP 290/415 filters work, common rollout mistakes, and how Safeguard closes the gaps.

Aman Khan
AppSec Engineer
7 min read

In April 2019, attackers began exploiting CVE-2019-2725, an unauthenticated deserialization flaw in Oracle WebLogic Server, to drop cryptominers and ransomware on production systems within days of proof-of-concept code going public. It was not a novel bug class — Chris Frohoff and Gabriel Lawrence had demonstrated the underlying technique, using "gadget chains" in common libraries like Apache Commons Collections, at AppSecCali back in January 2015. Four years later, the same class of vulnerability was still taking down enterprise Java systems, because most applications had no way to control what ObjectInputStream was allowed to instantiate. Java's answer, deserialization filters, has existed since JDK 9 and was backported to JDK 8u121, yet it remains one of the most underused hardening controls in the ecosystem. Here's how the filters work, what changed in JDK 17, and how to deploy them without breaking production.

What Is Insecure Deserialization, and Why Has It Hit Java So Hard?

Insecure deserialization happens when an application reconstructs Java objects from untrusted byte streams without restricting which classes can be instantiated, letting an attacker smuggle in a chain of otherwise-harmless classes whose side effects add up to remote code execution. Java is especially exposed because ObjectInputStream.readObject() will happily instantiate any class on the classpath that implements Serializable, and it runs constructors, readObject() overrides, and finalize() methods along the way — none of which were designed with adversarial input in mind. Researchers call the exploitation technique a "gadget chain": individual classes in libraries like Apache Commons Collections (CVE-2015-7501), Spring, Groovy, or Hibernate are individually benign, but chained together they can invoke Runtime.exec() or write arbitrary files. The ysoserial tool, first released in 2015, now ships more than 20 ready-made gadget chains covering dozens of common libraries, which is why security teams treat any raw deserialization endpoint as a de facto RCE risk until proven otherwise.

How Do Deserialization Filters Actually Work?

Deserialization filters work by intercepting the deserialization process before an object is fully constructed and rejecting it based on class name, array length, graph depth, reference count, or stream size. Introduced under JEP 290 in JDK 9 (September 2017) and backported to 8u121, 7u131, and 6u141, the mechanism gives you a ObjectInputFilter interface with a checkInput() callback that fires for every class the stream tries to resolve. A filter can be attached three ways: per-stream via ObjectInputStream.setObjectInputFilter(), globally via the jdk.serialFilter system property or java.security file entry, or — since JDK 17 — per JVM-wide factory. The filter returns ALLOWED, REJECTED, or UNDECIDED for each class, and a typical production pattern is an allowlist: reject everything by default, then explicitly permit the handful of classes (java.util.ArrayList, your DTOs, a few java.time types) that the application legitimately needs. Because the check happens before the object graph is walked, a rejected class never gets to run a constructor or readObject() method, which closes off gadget chains before they start.

What Changed with JEP 415 in JDK 17?

JEP 415, delivered in JDK 17 in September 2021, changed the model from a single global filter to context-specific filters set through a BinaryOperator<ObjectInputFilter> factory registered once at JVM startup. Before JEP 415, a single jdk.serialFilter had to cover every ObjectInputStream in the process, which was unworkable for applications like app servers or RMI registries that host multiple tenants or components each needing different allowlists. The factory pattern lets you inspect the current filter and the stream being created, then return a tailored filter — for example, a strict allowlist for a public-facing RMI endpoint and a looser one for internal batch jobs. JEP 415 also made filters "sticky": once a stream has a filter, later code cannot silently replace it with null, closing a bypass that existed in the original JEP 290 design. If you're still targeting JDK 8 or 11, you get the security benefit without the flexibility; JDK 17+ is where filter policy actually becomes manageable at scale.

How Do You Write an Effective Allowlist Filter Without Breaking the Application?

You write an effective filter by starting from a denylist-derived allowlist built from real traffic, not from guessing which classes "should" be safe. The simplest workable pattern is the built-in pattern-based filter syntax: a semicolon-separated list such as com.acme.dto.*;java.util.*;!* — meaning allow anything under your own DTO package and java.util, then reject everything else with the trailing !*. Oracle's own guidance and multiple CERT advisories recommend generating the initial allowlist by running the filter in logging-only mode (ObjectInputFilter.Config.createFilter() combined with a custom filter that logs UNDECIDED classes instead of rejecting them) against a full regression suite, then converting the observed class list into an enforced allowlist before shipping. Skipping this step is the single most common cause of filter rollouts breaking production: teams write filters by inspecting source code, miss a class that only appears in an edge-case error path, and the first user to hit that path gets an InvalidClassException instead of a working feature. Depth and array-length limits (maxdepth=, maxarray=) should also be set conservatively — Oracle's reference examples typically cap depth at 20-30 and array length well below the JVM default — since even an allowlisted class can be abused via excessively large or deeply nested graphs to cause denial of service.

What Are the Most Common Mistakes Teams Make When Deploying Filters?

The most common mistake is applying a global JVM filter and assuming it protects every deserialization path, when in practice many RCEs in 2023-2025 disclosures were found in code paths that bypass ObjectInputStream entirely — JSON libraries with polymorphic type handling (Jackson's enableDefaultTyping), XML deserializers (XStream, XMLDecoder), or custom RMI/JMX endpoints that predate the filter being registered. A second frequent gap is filter drift: an allowlist tuned against last year's dependency tree silently grows stale as new DTOs and third-party library versions are added, and because UNDECIDED results default to ALLOWED unless the filter explicitly ends in a reject clause, a forgotten trailing !* quietly reopens the whole class of vulnerability. Third, teams frequently protect the obvious readObject() entry points — session storage, cache deserialization, message queue consumers — but forget internal ones like RMI distributed garbage collection or JMX remote listeners, both of which have been the subject of CVEs (including patches Oracle shipped as recently as its April 2024 and January 2025 Critical Patch Updates) precisely because they deserialize before any application-level filter is consulted.

How Safeguard Helps

Safeguard closes the gap between "we added a filter somewhere" and "every deserialization path in this application is actually covered." Our software supply chain security platform scans your dependency graph to flag libraries with known gadget-chain classes — Commons Collections, Groovy, Spring, XStream, and the others tracked by ysoserial and the CVE databases — and cross-references them against the actual classes reachable from your deserialization entry points, so you know precisely which allowlist entries matter and which are dead weight. Because filter drift is a maintenance problem as much as a design problem, Safeguard continuously re-evaluates your SBOM against newly disclosed gadget chains and deserialization CVEs, alerting you when a dependency bump introduces a class that would slip past an existing filter's UNDECIDED default. For teams migrating from JDK 8 or 11 to 17+, Safeguard's build-pipeline checks can verify that jdk.serialFilter or JEP 415 factory configuration is present and enforced before artifacts are promoted, rather than relying on code review to catch a missing filter in a new microservice. And because insecure deserialization is a supply chain risk as much as a code-level one — the exploitable classes almost always live in a third-party dependency, not your own code — Safeguard's provenance and SBOM tooling gives security teams the visibility to answer "which of our services can even reach a gadget chain" in minutes instead of during incident response. That's the difference between finding CVE-2019-2725-class bugs in a postmortem and catching them before the artifact ships.

Never miss an update

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