luap.in/java
← Index · 06 · JVM Verified on JDK 21.0.10

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
OffsetBytesDecode
0x00ac edSTREAM_MAGIC — every Java serialization stream starts here
0x0200 05STREAM_VERSION = 5
0x0473TC_OBJECT — an object follows
0x0572TC_CLASSDESC — first, its class descriptor
0x0600 09class name length = 9
0x0853 65 72 24 …Ser$Token — the class name travels in the stream
0x1100 00 … 01serialVersionUID = 1
0x1902flags = SC_SERIALIZABLE
0x1A00 02field count = 2
0x1C49'I' — an int field follows
The single most important line above

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, equals or toString is invoked during deserialization.
  • Link gadgets that forward a call to a field.
  • A terminal that reaches reflection or process execution.
Which libraries are "vulnerable"

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:

01
Entry: an annotation handler's readObject

sun.reflect.annotation.AnnotationInvocationHandler holds a Map and, while deserializing, calls a method on it. The attacker supplies a map of their choosing.

02
Link: 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.

03
Link: ChainedTransformer

Applies an array of Transformers in sequence, feeding each result into the next. The attacker controls the array — this is the programmable part.

04
Terminal: 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();
}
PatternEffect
com.example.Sessionallow 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=500graph limits — also caps billion-laughs style DoS
Ordering is the whole control

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:

MechanismWhy it is the same bugFix
XMLDecoder.readObjectThe XML specifies classes and method calls. Strictly worse than binary serializationNever on untrusted input. No filter exists
Jackson activateDefaultTypingJSON carries a type field; Jackson instantiates itTurn it off; use concrete target types
@JsonTypeInfo(use = Id.CLASS)Same, scoped to one type hierarchyId.NAME with a registered subtype list
SnakeYAML new Yaml().load()!!com.example.Foo tags construct arbitrary typesnew Yaml(new SafeConstructor())
Spring SerializationUtils.deserializeA thin wrapper over ObjectInputStreamSame 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:

  1. Does untrusted data reach a deserialization sink? Trace it. A readObject on a file the application wrote itself is not the same finding as one on a request body or cookie.
  2. Is there a filter, and does it close with !*? An open-ended filter is close to no filter.
  3. 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.
Write it honestly

"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.

Check yourself
  1. Why does checking instanceof after readObject() returns not protect you?
    Because readObject executes code on the way in. readOrdinaryObject resolves the class named in the stream, allocates it, then readSerialData invokes that class's own readObject by reflection — for every object in the graph — before control returns. A ClassCastException in your log is often evidence of a successful attack, not a blocked one.
  2. 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.
  3. 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.
  4. You find rO0AB at the start of a cookie value. What is it, and what do you check next?
    It is base64 for AC ED 00 05 — the Java serialization stream magic and version. The application is round-tripping a serialized object through the client. Next: find the ObjectInputStream that reads it, check whether an ObjectInputFilter is set and whether it closes with !*, and enumerate WEB-INF/lib for known chain-bearing libraries.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3