Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Insecure Deserialisation

Why readObject on untrusted data is remote code execution, how gadget chains work, ObjectInputFilter as a mitigation, and the Jackson polymorphic typing configuration to avoid.

Advanced5 min readUpdated
On this page

Deserialising untrusted data is one of the few vulnerability classes that goes directly from a byte stream to arbitrary code execution, with no memory corruption and no exotic technique required.

Key Takeaways

  • ObjectInputStream.readObject on attacker-influenced bytes is remote code execution.
  • A gadget chain uses classes already on your classpath — you cannot fix it by removing your own code.
  • ObjectInputFilter is a mitigation, not a cure.
  • Jackson default typing reintroduces the same problem in JSON.
  • The real fix is to not deserialise untrusted data into arbitrary types.

How it works

The attacker writes no code. They assemble classes already on your classpath into a chain whose side effects reach an execution sink.

The key insight is that deserialisation is not passive. readObject runs constructors, custom readObject methods, readResolve and finalizers, all before your code sees the result. A gadget chain exploits classes whose deserialisation side effects can be composed into something dangerous.

ysoserial generates working payloads for dozens of chains across Commons Collections, Spring, Groovy, Hibernate and many others. You do not need a vulnerable library — you need any of those on the classpath, which is nearly every Java application.

Because the chain uses library code, you cannot fix it by auditing your own. Removing one library often just moves the attack to another.

The rule

NeverDoThis.java
// Remote code execution. Every one of these is a critical vulnerability.
public Object fromRequest(HttpServletRequest request) throws Exception {
    return new ObjectInputStream(request.getInputStream()).readObject();
}
 
public Session fromCookie(String base64) throws Exception {
    byte[] bytes = Base64.getDecoder().decode(base64);
    return (Session) new ObjectInputStream(new ByteArrayInputStream(bytes)).readObject();
}
 
public Message fromQueue(byte[] body) throws Exception {
    return (Message) new ObjectInputStream(new ByteArrayInputStream(body)).readObject();
}

The last one is the least obvious and the most common. A message broker is not a trust boundary you control end to end — anyone who can publish to the queue can send a payload, and in many deployments that is a wider set of parties than expected.

The same applies to Redis and Memcached when configured with JDK serialisation, which is why the caching guidance is always to use JSON.

ObjectInputFilter

When you genuinely cannot remove serialisation — a legacy protocol, a third-party library — Java 9+ provides a filter:

FilteredDeserialization.java
public Object deserializeSafely(byte[] data) throws IOException, ClassNotFoundException {
    try (var in = new ObjectInputStream(new ByteArrayInputStream(data))) {
 
        // Allowlist. Everything not named is rejected, including the gadget
        // classes an attacker would need.
        var filter = ObjectInputFilter.Config.createFilter(
                "com.acme.dto.*;"
              + "java.lang.String;java.lang.Number;java.lang.Integer;java.lang.Long;"
              + "java.util.List;java.util.ArrayList;java.util.Map;java.util.HashMap;"
              // Resource limits: a small payload can otherwise expand into
              // billions of objects and exhaust memory.
              + "maxdepth=10;maxarray=1000;maxrefs=1000;maxbytes=1048576;"
              + "!*");     // reject everything else
 
        in.setObjectInputFilter(filter);
        return in.readObject();
    }
}
a JVM-wide filter
java -Djdk.serialFilter='com.acme.**;java.base/*;!*' -jar app.jar

Treat this as damage limitation. An allowlist is only as good as its completeness, and a permitted class that itself holds a dangerous field reopens the door. It buys time to remove the serialisation, not permission to keep it.

Jackson

JSON has no type information, which is what makes it safer. Polymorphic typing puts it back:

JacksonTyping.java
// DANGEROUS: the payload names the class to instantiate.
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping();   // removed in Jackson 2.10+ for exactly this reason
 
// Also dangerous — an unbounded base type with no validator.
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = As.PROPERTY, property = "@class")
public interface Command { }
SafeJackson.java
// Safe: logical names mapped to a closed set of classes. The payload can only
// select from types you registered.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = As.PROPERTY, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(value = PlaceOrder.class, name = "placeOrder"),
    @JsonSubTypes.Type(value = CancelOrder.class, name = "cancelOrder")
})
public sealed interface Command permits PlaceOrder, CancelOrder { }
 
// If class-based typing is unavoidable, constrain it explicitly.
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType("com.acme.commands.")
        .build();
ObjectMapper mapper = JsonMapper.builder()
        .activateDefaultTyping(validator, DefaultTyping.NON_FINAL)
        .build();

Logical type names have a second benefit beyond safety: they decouple the wire format from your package structure, so renaming a class does not break every stored message.

Message brokers

application.yml
spring:
  kafka:
    consumer:
      properties:
        # An allowlist. '*' means the header names any class and your JVM
        # constructs it — the same vulnerability with different plumbing.
        spring.json.trusted.packages: 'com.acme.events'
        spring.json.use.type.headers: false
        spring.json.value.default.type: com.acme.events.OrderEvent

Disabling type headers entirely and setting a default type is stronger than an allowlist, because it removes the attacker's influence over type selection completely. Use it wherever a topic carries one event type.

Preferring a schema

The structural fix is a format where types are not expressible in the payload at all:

FormatType selectionAssessment
Java serialisationPayload names the classNever for untrusted input
Jackson default typingPayload names the classNever
Jackson with a sealed hierarchyClosed set you definedSafe
ProtobufSchema, compiledSafe
AvroSchema, registrySafe

Protobuf and Avro cannot instantiate an arbitrary class, because the schema defines what is representable and the generated code is what parses it. For service-to-service messaging that is worth the schema tooling on security grounds alone.

Detection

Add a build-time gate and a runtime signal.

SpotBugs with FindSecBugs flags OBJECT_DESERIALIZATION at compile time, which is where you want to catch a newly introduced readObject.

Log rejected classes. An ObjectInputFilter that logs what it refused turns an attempted exploit into an alert rather than a silent block. Similarly, watch for known gadget class names — Commons Collections transformer classes, TemplatesImpl — appearing in logs or error messages, since their presence in a request payload is not accidental.

What to take away

Never call readObject on anything that crossed a trust boundary — including messages from a broker and values from a cache. Use JSON with a closed type hierarchy, or a schema format that cannot express a class name at all. If legacy serialisation is unavoidable, apply an allowlist filter with resource limits and treat it as time bought to remove it.

Frequently Asked Questions

Is Java serialisation always dangerous?
It is dangerous whenever the bytes could be influenced by an attacker. Deserialising data your own process wrote and stored somewhere they cannot reach is fine. The rule that keeps you safe is simple: never call readObject on anything that crossed a trust boundary.
How does deserialising data execute code?
readObject reconstructs an object graph, invoking readObject, readResolve and finalizers along the way. A gadget chain strings together classes already on your classpath whose deserialisation side effects, combined, reach a method that executes a command. The attacker writes no code — they assemble yours.
Is JSON safe?
JSON itself carries no type information, so it is much safer. The danger is polymorphic typing — Jackson enableDefaultTyping, or @JsonTypeInfo without a validator — which lets the payload name the class to instantiate, reintroducing the same problem.

Related tutorials