Safeguard
Security Guides

Java Deserialization Vulnerabilities: How Gadget Chains Work and How to Stop Them

Native Java deserialization can turn a single readObject() call into remote code execution. Here's how gadget chains work and how to shut them down.

Marcus Chen
AppSec Engineer
6 min read

If you had to pick the single most dangerous line of code in the Java standard library, ObjectInputStream.readObject() on attacker-controlled bytes would be a strong contender. It looks innocuous — you're just turning bytes back into an object — but under the hood it can instantiate arbitrary classes and invoke their lifecycle methods before your code ever sees the result. Chain the right classes together and that single call becomes remote code execution, no memory-corruption bug required. This class of flaw has been quietly behind a long line of critical CVEs, and it keeps resurfacing because the underlying API is still there and still trusted by too many systems.

What is a Java deserialization vulnerability?

A deserialization vulnerability exists whenever an application reconstructs Java objects from data an attacker can influence, using a mechanism that executes code during reconstruction. Java's native serialization is the classic case: readObject() will happily instantiate any Serializable class present on the classpath and call its custom deserialization hooks. The attacker doesn't need your classes to be malicious — they only need ordinary library classes whose side effects, strung together, do something dangerous. That string of classes is called a gadget chain.

How gadget chains actually work

A gadget chain abuses the side effects that fire automatically during deserialization — methods like readObject, readResolve, or callbacks triggered when an object is placed into a HashMap (which calls hashCode() and equals()). An attacker crafts a serialized object graph so that reconstructing it triggers a sequence: one gadget's deserialization hook calls into another, which calls into another, until the final gadget invokes something like Runtime.exec() or a template engine that evaluates arbitrary expressions.

The open-source ysoserial toolkit made this practical for researchers and attackers alike, cataloging more than twenty ready-made chains across widely used libraries — Apache Commons Collections, Spring, Groovy, and others. The critical insight for defenders: the vulnerable library often isn't "buggy" in the traditional sense. Commons Collections' InvokerTransformer was doing exactly what it was designed to do; it just happened to be a reachable link in a chain. That's why you can't fix this class of problem purely by patching individual CVEs — you have to change how untrusted data enters the deserializer.

The wrong way and the right way to receive objects

Here is the pattern that gets shops compromised — accepting a serialized blob over the network and deserializing it directly:

// DANGEROUS: any class on the classpath can be instantiated
try (ObjectInputStream ois = new ObjectInputStream(request.getInputStream())) {
    Order order = (Order) ois.readObject();
}

The most robust fix is to stop using native serialization for untrusted input entirely. Use a data-oriented format that describes values, not executable object graphs:

// SAFE: JSON maps to a known target type, no arbitrary class instantiation
ObjectMapper mapper = new ObjectMapper();
mapper.deactivateDefaultTyping(); // never trust type info from the wire
Order order = mapper.readValue(request.getInputStream(), Order.class);

Note the deactivateDefaultTyping() call. Jackson's polymorphic typing (enableDefaultTyping) reintroduces the same problem by letting the wire data name the class to instantiate — several Jackson databind CVEs stem exactly from this. Keep it off unless you fully control both ends and have an allow-list of permitted types.

When you must deserialize: filter aggressively

If a legacy protocol forces native serialization, install an ObjectInputFilter (introduced in JDK 9, backported to 8u121) that allow-lists only the classes you expect and rejects everything else:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
    "com.acme.dto.Order;com.acme.dto.LineItem;java.util.ArrayList;"
    + "java.lang.Number;!*");   // trailing !* blocks all others

try (ObjectInputStream ois = new ObjectInputStream(request.getInputStream())) {
    ois.setObjectInputFilter(filter);
    Order order = (Order) ois.readObject();
}

Set a conservative filter globally as a JVM-wide backstop with -Djdk.serialFilter= so that even code paths you forgot about are protected. Also apply resource limits in the filter (maximum array size, depth, and references) to blunt denial-of-service payloads that expand into billions of objects.

Where deserialization sinks actually hide

The reason this vulnerability class is so persistent is that native deserialization is rarely something a developer writes on purpose — it's buried inside frameworks and protocols. RMI, JMX, some caching layers, session-replication mechanisms in application servers, and message queues have all historically used Java serialization under the hood. That's why a code review looking for literal readObject() calls will miss most real exposure: the dangerous call lives in a dependency, invoked on data that crossed a trust boundary you didn't realize was a deserialization entry point. When you audit for this, follow the data, not the API — ask "what untrusted bytes does this system accept, and does anything downstream turn them back into objects?" That question surfaces the JMX endpoint or the RMI registry that a grep for ObjectInputStream never would.

Detection and defense summary

ControlWhat it stopsEffort
Replace native serialization with JSON/ProtobufThe entire gadget-chain classMedium (refactor)
Disable Jackson default typingPolymorphic-typing RCELow
ObjectInputFilter allow-listUnexpected class instantiationLow
Global -Djdk.serialFilter backstopForgotten code pathsLow
Dependency scanning for known gadget libsReachable chains in your treeLow (automated)

How Safeguard helps

Deserialization risk is uniquely hard to triage by CVSS alone, because a "critical" gadget library is only dangerous if the vulnerable code path is actually reachable from data you accept. Safeguard's reachability analysis traces whether a known gadget-chain class — say, an InvokerTransformer path in an outdated Commons Collections, or a Jackson polymorphic-typing sink — is invokable from your application's entry points, so you fix the chains that matter rather than every library that appears in a scanner. Its software composition analysis surfaces those gadget-capable libraries even when they're pulled in three layers deep as transitive dependencies, and auto-fix pull requests upgrade them to patched versions automatically. If you also expose these endpoints over HTTP, pairing SCA with dynamic application security testing validates whether a deserialization sink is actually reachable from the outside.

Spin up a free scan at app.safeguard.sh/register, and see the deserialization detection docs at docs.safeguard.sh.

Never miss an update

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