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.
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.readObjecton 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.
ObjectInputFilteris 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 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
// 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:
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();
}
}java -Djdk.serialFilter='com.acme.**;java.base/*;!*' -jar app.jarTreat 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:
// 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 { }// 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
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.OrderEventDisabling 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:
| Format | Type selection | Assessment |
|---|---|---|
| Java serialisation | Payload names the class | Never for untrusted input |
| Jackson default typing | Payload names the class | Never |
| Jackson with a sealed hierarchy | Closed set you defined | Safe |
| Protobuf | Schema, compiled | Safe |
| Avro | Schema, registry | Safe |
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?
How does deserialising data execute code?
Is JSON safe?
Related tutorials
- SSRF PreventionStopping server-side request forgery: why cloud metadata endpoints are the prize, validating URLs correctly, defeating DNS rebinding, and egress controls as a second layer.
- OWASP Top 10 for LLM ApplicationsSecuring AI features in a Spring application: why prompt injection cannot be fully solved, treating model output as untrusted, capability scoping for agents, and cost-based denial of service.
- File Upload SecurityEvery attack a file upload enables and its defence: extension allowlists, real content-type detection with Tika, path traversal, polyglot files, SVG, and safe serving.
- XSS PreventionStopping cross-site scripting: the three XSS types, why encoding must be context-aware, Thymeleaf escaping, OWASP Java Encoder, nonce-based CSP and sanitising rich text.