Deserialization and gadget chains
The stream names the classes. readObject constructs
them, calls their own readObject methods, and only then returns a
reference for you to cast. By the time your instanceof check
runs, the attacker's code has already executed.
01The wire format, verified
A 77-byte stream holding one small object, produced on JDK 21.0.10:
static class Token implements Serializable { private static final long serialVersionUID = 1L; String user = "paul"; int level = 3; }
$ xxd -l 32 token.ser 00000000: aced 0005 7372 0009 5365 7224 546f 6b65 ....sr..Ser$Toke 00000010: 6e00 0000 0000 0000 0102 0002 4900 056c n...........I..l
| Offset | Bytes | Decode |
|---|---|---|
| 0x00 | ac ed | STREAM_MAGIC — every Java serialization stream starts here |
| 0x02 | 00 05 | STREAM_VERSION = 5 |
| 0x04 | 73 | TC_OBJECT — an object follows |
| 0x05 | 72 | TC_CLASSDESC — first, its class descriptor |
| 0x06 | 00 09 | class name length = 9 |
| 0x08 | 53 65 72 24 … | Ser$Token — the class name travels in the stream |
| 0x11 | 00 00 … 01 | serialVersionUID = 1 |
| 0x19 | 02 | flags = SC_SERIALIZABLE |
| 0x1A | 00 02 | field count = 2 |
| 0x1C | 49 | 'I' — an int field follows |
At offset 0x08 the stream contains a class name, chosen by whoever wrote the stream. Deserialization is not "fill in my object" — it is "construct whatever this data names". That inversion is the entire vulnerability class.
Spotting it in traffic
Base64-encoded, ac ed 00 05 becomes the prefix rO0AB:
$ base64 < token.ser | head -c 12 rO0ABXNyAAlT ^^^^^ this prefix in a cookie, header, form field or JSON string means a Java object stream
# Sweep a traffic capture or log for both forms $ grep -rao 'rO0AB[A-Za-z0-9+/=]*' ./logs | head $ grep -rlaP '\xac\xed\x00\x05' ./captures
02Why the cast is too late
The ordering inside ObjectInputStream is what makes this
unfixable at the call site:
readObject() └─ readObject0() └─ readOrdinaryObject() ├─ readClassDesc() // resolve the class NAMED IN THE STREAM ├─ newInstance() // allocate, no constructor for Serializable └─ readSerialData() └─ invokeReadObject() // calls the CLASS'S OWN readObject — // arbitrary code, by reflection // ... only now does readObject() return Session s = (Session) in.readObject(); // <-- your check happens HERE
Each nested object repeats the cycle. A stream containing a graph of a hundred
objects runs a hundred readObject methods before a single line of your
code executes.
// All three of these are equally too late Object o = in.readObject(); if (!(o instanceof Session)) throw new SecurityException(); if (!o.getClass().getName().startsWith("com.example")) throw …; try { Session s = (Session) o; } catch (ClassCastException e) { … }
The payload never needs to be a Session. It only needs to
be deserialized. A ClassCastException in your log is often the
evidence of a successful attack, not the sign of a blocked one.
03What a gadget chain is
An attacker does not ship code in the stream — bytecode is not transmitted. They ship data that steers classes already on your classpath into doing something useful.
A gadget is any class that, during deserialization or shortly after, calls a method on a field it holds. Chain enough of them and the final call is something dangerous. The requirements:
- An entry gadget whose
readObject,readResolve,hashCode,equalsortoStringis invoked during deserialization. - Link gadgets that forward a call to a field.
- A terminal that reaches reflection or process execution.
None of them, in the usual sense. InvokerTransformer doing
reflective invocation is its documented purpose. The defect is in the application
that deserializes untrusted data; the library merely supplies reachable behaviour.
This matters for triage. "Remove commons-collections" is not a fix — new chains are published continuously across many libraries, and the next scan will find a different one. The fix is to stop deserializing untrusted streams.
04The CommonsCollections shape
The classic chain, described structurally rather than as a working payload:
sun.reflect.annotation.AnnotationInvocationHandler holds a
Map and, while deserializing, calls a method on it. The attacker
supplies a map of their choosing.
LazyMap
org.apache.commons.collections.map.LazyMap computes missing values
on demand. A lookup for an absent key calls factory.transform(key).
The call is now in attacker-chosen territory.
ChainedTransformer
Applies an array of Transformers in sequence, feeding each result
into the next. The attacker controls the array — this is the programmable part.
InvokerTransformer
Calls a named method, with named argument types, on whatever it is handed —
by reflection. Chained a few times it walks from a Class object to
Runtime.getRuntime() to exec.
// Structure of the transformer array — the "program" in the data. new ConstantTransformer(Runtime.class), new InvokerTransformer("getMethod", …, {"getRuntime", …}), new InvokerTransformer("invoke", …, {null, …}), new InvokerTransformer("exec", …, {command})
Read it as four reflective steps: get the Runtime class, find
getRuntime, invoke it, then invoke exec on the result.
Every individual operation is legitimate; the composition is the exploit — the same
structure as the S2-045 chain, where each step
was also ordinary code.
Tooling (ysoserial and successors) maintains dozens of these across
Commons Collections, Commons BeanUtils, Spring, Groovy, Hibernate, ROME, C3P0 and
more. Assume that if a stream reaches readObject on a realistic
classpath, a chain exists.
05ObjectInputFilter
Java 9 added a hook consulted per class, before instantiation — early enough to matter.
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "com.example.Session;" + // allow exactly what we expect "java.base/java.lang.String;" + "!*"); // reject everything else try (ObjectInputStream in = new ObjectInputStream(src)) { in.setObjectInputFilter(filter); Session s = (Session) in.readObject(); }
| Pattern | Effect |
|---|---|
| com.example.Session | allow exactly this class |
| com.example.* | allow the package, not subpackages |
| com.example.** | allow the package and subpackages |
| !org.apache.commons.** | reject this subtree |
| !* | reject anything not already allowed |
| maxdepth=10;maxrefs=500 | graph limits — also caps billion-laughs style DoS |
Patterns are evaluated left to right and the first match decides.
A filter that does not end in !* allows everything it did not
explicitly reject — an allow-list that silently behaves as a deny-list.
"!org.apache.commons.**" // blocks one library, allows the rest
"com.example.**;java.base/*;!*" // allow-list, closed at the end
A JVM-wide default
# Applies to every ObjectInputStream without its own filter $ java -Djdk.serialFilter='!org.apache.commons.**;!bsh.**;maxdepth=20;!*' … # For the lab Tomcat: $ CATALINA_OPTS="-Djdk.serialFilter=com.example.**;java.base/*;!*" \ ~/tools/tomcat/bin/catalina.sh run
Useful as a compensating control when you cannot change the code — a legacy application, a third-party WAR. It is a mitigation with real bypass risk if the allow-list is wide, not a substitute for removing the sink.
What it costs you
A filter is a behaviour change. Streams that used to deserialize now throw
InvalidClassException: filter status: REJECTED. That is the point, but
it means the allow-list has to be derived from what the application legitimately
sends — log rejections in a staging environment before enforcing in production.
06The same bug in other parsers
Java serialization is the famous case. The pattern — data naming the type to construct — recurs:
| Mechanism | Why it is the same bug | Fix |
|---|---|---|
XMLDecoder.readObject | The XML specifies classes and method calls. Strictly worse than binary serialization | Never on untrusted input. No filter exists |
Jackson activateDefaultTyping | JSON carries a type field; Jackson instantiates it | Turn it off; use concrete target types |
@JsonTypeInfo(use = Id.CLASS) | Same, scoped to one type hierarchy | Id.NAME with a registered subtype list |
SnakeYAML new Yaml().load() | !!com.example.Foo tags construct arbitrary types | new Yaml(new SafeConstructor()) |
Spring SerializationUtils.deserialize | A thin wrapper over ObjectInputStream | Same as above; deprecated for this reason |
XMLDecoder deserves its own warning. It is not a deserializer that happens to be
dangerous — it is a scripting format. A document can express
<object class="java.lang.ProcessBuilder"> directly, with no gadget
chain required. There is no safe configuration for untrusted input.
07Finding it
# Sinks in source $ grep -rnE 'ObjectInputStream|readObject\(|XMLDecoder|SerializationUtils\.deserialize' src/ # Polymorphic JSON and YAML $ grep -rnE 'activateDefaultTyping|enableDefaultTyping|JsonTypeInfo|new Yaml\(' src/ # Classes that implement readObject — your own potential gadgets $ grep -rn 'private void readObject' src/ # Known chain-bearing libraries actually present in the artifact $ unzip -Z1 app.war 'WEB-INF/lib/*' | grep -iE \ 'commons-collections|commons-beanutils|groovy|rome|c3p0|javassist|aspectjweaver' # Is a filter configured anywhere? $ grep -rn 'setObjectInputFilter\|jdk.serialFilter' src/ conf/ bin/
Reaching a verdict
The questions, in the order that settles the finding:
- Does untrusted data reach a deserialization sink? Trace it. A
readObjecton a file the application wrote itself is not the same finding as one on a request body or cookie. - Is there a filter, and does it close with
!*? An open-ended filter is close to no filter. - What is on the classpath? Relevant to exploitability — but absence of a known-gadget library is not a clean bill of health. It bounds your demonstration, not the risk.
"Untrusted input reaches ObjectInputStream.readObject at
SessionServlet:48 with no ObjectInputFilter; commons-collections
3.2.1 is present in WEB-INF/lib" is a finding with a traced path and a
stated classpath.
"commons-collections 3.2.1 is present" is a dependency observation. On its own it says nothing about whether this application deserializes anything an attacker controls — and reporting it as a vulnerability is how a dependency scanner's output gets ignored wholesale.
Why does checking
instanceofafterreadObject()returns not protect you?BecausereadObjectexecutes code on the way in.readOrdinaryObjectresolves the class named in the stream, allocates it, thenreadSerialDatainvokes that class's ownreadObjectby reflection — for every object in the graph — before control returns. AClassCastExceptionin your log is often evidence of a successful attack, not a blocked one.A reviewer proposes the filter
"!org.apache.commons.**". What is wrong with it?Patterns are evaluated left to right and the first match decides, so this rejects one library and allows everything else — an allow-list that behaves as a deny-list. It must be an allow-list closed at the end:"com.example.**;java.base/*;!*". The trailing!*is the control.The team removed commons-collections from the classpath. Is the vulnerability fixed?
No. The defect is that the application deserializes untrusted data; the library only supplied reachable behaviour. Chains exist across Commons BeanUtils, Spring, Groovy, Hibernate, ROME, C3P0 and others, and new ones are published continuously. Removing one library changes what you can demonstrate, not whether the application is vulnerable.You find
rO0ABat the start of a cookie value. What is it, and what do you check next?It is base64 forAC ED 00 05— the Java serialization stream magic and version. The application is round-tripping a serialized object through the client. Next: find theObjectInputStreamthat reads it, check whether anObjectInputFilteris set and whether it closes with!*, and enumerateWEB-INF/libfor known chain-bearing libraries.