Java Deserialization: ObjectInputStream and the Gadget-Chain Problem
Calling ObjectInputStream.readObject() on bytes an attacker controls is enough to reach Runtime.exec during deserialization itself. Here is why a gadget chain runs even though your code never expected that type, and how look-ahead filtering shuts it down.
rO0ABXNyABZqYXZhLnV0aWwOn this page
Java serialization turns an object graph into a byte stream, and ObjectInputStream.readObject() rebuilds that graph on the other side. The trap is that readObject does not just allocate fields — it runs reconstruction logic on every class in the stream, including classes you never intended to receive. The moment you call it on bytes an attacker controls, you have handed them a foothold inside your process, and a well-known gadget chain turns that foothold into remote code execution.
This is one of the most reliable RCE primitives on the JVM, and it has been weaponised into push-button tooling. This post shows how an untrusted serialized blob reaches Runtime.exec during deserialization, why declaring the "wrong" expected type does not save you, and how a look-ahead allowlist closes the hole.
How it happens
The root cause is treating a serialized stream from an HTTP body, a queue message or an RMI call as if it were safe data. A common shape reads an object straight off the wire and casts it to the type the code expects:
public Object load(byte[] body) throws Exception { try (var in = new ObjectInputStream(new ByteArrayInputStream(body))) { Session session = (Session) in.readObject(); return session.getUser(); } }
The developer assumes the bytes still describe a Session. But the cast happens after readObject returns — by then the entire stream has already been deserialized, every embedded class reconstructed and every magic method run. The declared type is a hint, not a gate.
You can recognise the input on sight. A serialized stream starts with the magic header AC ED 00 05, which base64-encodes to a leading rO0AB. Anywhere that prefix shows up — a cookie, a JSF ViewState field, an RMI/JMX payload — is a deserialization sink:
POST /api/session HTTP/1.1
Content-Type: application/octet-stream
rO0ABXNyABZqYXZhLnV0aWwuUHJpb3JpdHlRdWV1ZZTaMLT7P4KxAwACSQ...
A concrete attack
An attacker does not need a vulnerable class of yours. They reach into the libraries already on your classpath and wire existing methods into a gadget chain — a sequence that, triggered by deserialization, ends at a dangerous sink. Tools like ysoserial generate these for you. The classic CommonsCollections chain abuses a lazy Map whose value transformer is rebuilt to invoke reflection:
Transformer chain = new ChainedTransformer(new Transformer[] { new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[0]}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[0]}), new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"id > /tmp/pwned"}) });
That transformer is packed into a serialized LazyMap and submitted to the endpoint above. When readObject rebuilds the graph, restoring the map fires the transformer chain, which resolves Runtime.getRuntime().exec("id > /tmp/pwned") — all before your (Session) cast is ever evaluated. The Spring and Hibernate gadgets reach the same place through different intermediate classes. The attacker has gone from "I can post bytes" to "I run commands on your host."
The cast told you what you wanted; the stream decided what you got. This is not a parser bug —
ObjectInputStreamis doing exactly what it was designed to do, on input it should never have been allowed to read.
Why it matters
Because the chain executes inside the application process, during a single readObject call, the impact is severe:
- Remote code execution — arbitrary commands on the server, the usual end state of a gadget chain.
- Pre-authentication reach — RMI/JMX and
ViewStatesinks often sit in front of any login check. - Lateral movement — RMI registries and JMX consoles expose deserialization across internal services.
- Full host compromise — RCE leads directly to data theft, persistence and pivoting.
The fix: do not deserialize untrusted data
The only fully reliable rule is to never hand attacker-controlled bytes to Java's native deserializer. Prefer a data-only format — JSON or protobuf parsed into an explicit schema — so reconstructing a message can never instantiate an arbitrary class:
ObjectMapper mapper = JsonMapper.builder()
.disable(MapperFeature.USE_GETTERS_AS_SETTERS)
.build();
Session session = mapper.readValue(body, Session.class);
Where native serialization is genuinely unavoidable, enforce a look-ahead allowlist that vets each class before it is resolved, so an unexpected gadget type aborts the stream instead of running:
ObjectInputFilter allow = ObjectInputFilter.Config.createFilter(
"com.example.model.*;java.base/*;!*");
try (var in = new ObjectInputStream(new ByteArrayInputStream(body))) {
in.setObjectInputFilter(allow);
Session session = (Session) in.readObject();
return session.getUser();
}
On older JVMs, Apache Commons IO's ValidatingObjectInputStream gives the same per-class allowlist. Pair it with least-privilege accounts, and keep the allowlist tight — every class you admit is a class an attacker may try to chain.
How SelfSec finds it
SelfSec's crawler locates the parameters, cookies, JSON bodies and form fields that feed a deserializer, then submits known serialized markers — Java's serialized base64 preamble (rO0AB), PHP object and array structures, YAML python-object tags and fastjson type directives — and watches for deserialization-specific errors such as ClassNotFoundException, InvalidClassException and autoType that reveal an unguarded readObject sink. Matched evidence is rescored to separate a genuinely exploitable deserializer from incidental error output, so findings are confirmed rather than guessed. The entire scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with a reproduction request you can replay.
SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.
Do both things about Insecure Deserialization
SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.