Serialization 13 ๐ Deserialization Security โ Arbitrary Code Execution and Gadget Chains
Deserialization is the inverse of serialization: bytes become objects. The security risk is not in the format itself but in the deserializer’s willingness to construct whatever type the payload names. When an attacker controls the bytes, they control which classes are instantiated, which constructors run, and which methods execute. The result is arbitrary code execution without a single line of attacker-supplied code in the applicationโthe code already exists on the classpath, and the payload chains it together.
This chapter covers the vulnerability class (CWE-502), the gadget chain mechanism that turns object construction into code execution, the language-specific risky APIs, and the mitigations that close the hole. The sections that follow walk through the gadget chain mechanism, the risky calls per language, and the allowlist-and-avoidance strategies that prevent the attack.
Key point: A deserialization vulnerability is only exploitable when a gadget chain existsโa sequence of legitimate classes on the classpath that, when instantiated in a specific order, reaches a dangerous sink like Runtime.exec() or os.system(); the fix is to stop letting the payload choose the type.
a. The gadget chain mechanism
A gadget chain is a sequence of classes and methods that the deserializer invokes automatically. In Java, the chain typically starts with a class whose readObject method performs an action, continues through a map or collection whose entries trigger another method, and ends at a sink that executes the attacker’s command . The attacker does not need to upload code; the code is already present in the libraries the application depends on, and the payload arranges the calls.
The canonical example is the CommonsCollections chain. A HashSet containing a TiedMapEntry whose key is a LazyMap with a Transformer chain can trigger Runtime.exec() when the HashSet is deserialized, because readObject triggers hashCode(), which triggers the transformer chain . The chain is composed entirely of classes the application legitimately uses; the payload just orders them in a way the application never intended.
.NET has the same mechanism. The TypeConfuseDelegate gadget in ysoserial.net uses a ComparisonComparer whose delegate is a Func<string, string, int> pointing to Process.Start, packaged inside a SortedSet whose OnDeserialization callback triggers the comparison . The payload is a serialized object graph; the deserializer reconstructs it, and the reconstruction executes the chain.
The PHP equivalent is the POP (Property-Oriented Programming) chain. A class with a __destruct method that calls exec() can be reached through another class’s __toString or __wakeup method, which is triggered by unserialize() . The phpggc tool catalogs chains for Laravel, Symfony, Guzzle, Magento, and WordPress .
b. Risky APIs by language
The risky call is the one that lets the payload choose the type. Every language has at least one, and most have several.
In Java, ObjectInputStream.readObject() is the primary sink. Any class that implements Serializable can be instantiated, and its readObject, readResolve, readExternal, or readUnshared method runs. XMLDecoder and XStream.fromXML are also named by OWASP . The traffic signature is the byte prefix AC ED 00 05 or the Base64 prefix rO0 .
In .NET, BinaryFormatter is the canonical insecure deserializer. Microsoft’s documentation states it is insecure and cannot be made secure . SoapFormatter, LosFormatter, NetDataContractSerializer, and ObjectStateFormatter carry the same risk. Json.NET‘s TypeNameHandling setting is the JSON equivalent: when it is not None, the payload can name the type . The EasyFlow vulnerability (CVE-2026-102455) is an unauthenticated remote code execution through .NET deserialization, with the classic pattern: no input validation before reconstructing the object graph .
In Python, pickle.loads is the sink. The Python documentation says pickle can execute arbitrary code while loading, and yaml.load with the full loader is equally powerful . jsonpickle.decode is also named by OWASP . The pyquokka FlightServer vulnerability (CVE-2025-62515) is a direct pickle.loads on an action body received over the network, exploitable by any client that can connect .
In PHP, unserialize() on user data is the sink. The phpggc catalog lists dozens of chains for popular frameworks, including Laravel, Symfony, Guzzle, Magento2, and WordPress . The chain is triggered by magic methods: __destruct, __wakeup, __toString, or __call .
In Ruby, Marshal.load is the sink. The ERB vulnerability (CVE-2026-41316) bypasses a protection mechanism against malicious deserialization, and Rails applications that process Marshal data from insecure sources are exposed . The chain uses ERB’s template execution as the sink, reached through a gadget chain in ActiveSupport .
c. Mitigations: avoid, allowlist, and bound
The primary mitigation is to stop using native serialization for untrusted data. OWASP’s guidance is to prefer pure data formats such as JSON or XML with explicit types, because a data format cannot name arbitrary classes . JSON with TypeNameHandling.None in Json.NET, System.Text.Json in .NET, and yaml.safe_load in Python are the safe defaults.
When native serialization cannot be avoided, the second layer is an allowlist. Java’s serialization filters (ObjectInputFilter) restrict which classes can be deserialized, with limits on graph depth, array length, and stream size . The allowlist is stronger than a blocklist because it names what the application needs, not what attackers have already used . OWASP notes that filters do not make arbitrary untrusted deserialization safe; they reduce the attack surface when the format is unavoidable .
The third layer is resource bounding. A filter that only checks class names does not bound the size of the object graph, the depth of nesting, or the number of references. A payload can exhaust memory without executing code. The filter must set limits, and the input size must be capped separately because filters are not called for primitives .
The fourth layer is domain hardening. A class that must never be deserialized can define a final readObject() that always throws. Sensitive fields can be marked transient so they are not serialized. The resolveClass override is an illustration, not a full defense, because it does not check every object and proxy descriptors go through resolveProxyClass .
Complete Example Session
// ============================================
// PART 1: THE VULNERABLE PATTERN
// ============================================
import java.io.*;
// Reads whatever type the stream names
ObjectInputStream ois = new ObjectInputStream(untrustedStream);
Object obj = ois.readObject(); // gadget chain executes here
// ============================================
// PART 2: THE SAFE PATTERN (JSON WITH EXPLICIT TYPE)
// ============================================
// Jackson: no polymorphic type handling
ObjectMapper mapper = new ObjectMapper();
User user = mapper.readValue(json, User.class); // fixed type
// ============================================
// PART 3: THE ALLOWLIST FILTER (JAVA)
// ============================================
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.User;java.lang.String;!*" // allow only these
);
ObjectInputStream ois = new ObjectInputStream(stream);
ois.setObjectInputFilter(filter);
// ============================================
// PART 4: DOMAIN HARDENING
// ============================================
public class SensitiveClass implements Serializable {
private transient String secret; // not serialized
private final void readObject(ObjectInputStream in) throws IOException {
throw new InvalidObjectException("Not deserializable");
}
}
# ============================================
# PART 5: PYTHON SAFE PATTERN
# ============================================
import json
import yaml
# SAFE: data-only format
data = json.loads(untrusted_json)
# SAFE: YAML safe loader
data = yaml.safe_load(untrusted_yaml)
# UNSAFE: pickle on untrusted input
# import pickle
# data = pickle.loads(untrusted_bytes) # RCE
# ============================================
# PART 6: .NET SAFE PATTERN
# ============================================
// System.Text.Json: no type names by default
var user = JsonSerializer.Deserialize<User>(json);
// Json.NET: TypeNameHandling must be None
var settings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.None
};
The parts covered the vulnerable Java pattern, the safe JSON pattern, the Java allowlist filter, domain hardening, the Python safe loaders, and the .NET safe defaults.
Quick Reference
Risky APIs by Language
| Language | Risky Call | Safe Alternative |
|---|---|---|
| Java | ObjectInputStream.readObject() | JSON with fixed types, allowlist filter |
| .NET | BinaryFormatter | System.Text.Json |
| .NET (JSON) | TypeNameHandling โ None | TypeNameHandling.None |
| Python | pickle.loads, yaml.load | json.loads, yaml.safe_load |
| PHP | unserialize() | json_decode() |
| Ruby | Marshal.load | JSON, safe YAML |
Traffic Signatures
| Language | Signature |
|---|---|
| Java | AC ED 00 05 (hex), rO0 (Base64) |
| .NET | BinaryFormatter stream patterns |
| Python | Pickle opcodes |
Mitigation Layers
| Layer | Mechanism |
|---|---|
| Avoid | Data-only formats (JSON, safe YAML) |
| Allowlist | Serialization filter with expected classes |
| Bound | Graph depth, array length, stream size |
| Harden | readObject throws, transient fields |
Best Practices
โ Do This:
// Use a serialization filter with an allowlist
ois.setObjectInputFilter(filter); // โ
// Fix the target type in code
mapper.readValue(json, User.class); // โ
// Set TypeNameHandling to None
TypeNameHandling = TypeNameHandling.None; // โ
# Use safe_load for YAML
yaml.safe_load(untrusted); // โ
# Use json.loads for data
json.loads(untrusted); // โ
โ Don’t Do This:
// Read arbitrary types from untrusted stream
ois.readObject(); // โ
// Allow polymorphic type handling
TypeNameHandling = TypeNameHandling.All; // โ
// pickle.loads on network input
pickle.loads(untrusted); // โ
// unserialize() on user data
unserialize($_POST['data']); // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| RCE through “internal” queue | Trust boundary assumed, not verified | Treat all cross-process data as untrusted |
| Allowlist bypassed by proxy | resolveProxyClass not filtered | Filter proxy descriptors |
| Filter does not bound memory | Only class names checked | Add graph/array/stream limits |
| Gadget chain survives patch | Only known chain broken | Patch underlying primitives, not just the published exploit |
| JSON still vulnerable | TypeNameHandling not None | Set to None explicitly |
Real-World Examples
1. Java CommonsCollections chain
HashSet โ TiedMapEntry โ LazyMap โ Transformer โ Runtime.exec
2. .NET TypeConfuseDelegate
SortedSet โ ComparisonComparer โ Process.Start
3. PHP __destruct chain
unserialize โ __destruct โ exec
4. Python pickle RCE
class RCE:
def __reduce__(self):
return (os.system, ('ls -l',))
5. Java filter allowlist
ObjectInputFilter.Config.createFilter("com.example.User;!*");
6. .NET TypeNameHandling
TypeNameHandling = TypeNameHandling.None;
7. Python safe_load
yaml.safe_load(untrusted);
8. PHP json_decode
json_decode($input, true);
9. Ruby Marshal warning
# Avoid Marshal.load on untrusted data
10. ysoserial.net ViewState
ysoserial.exe -p ViewState --generator=<gen> --validationkey=<key>
Visual
The Gadget Chain
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PAYLOAD (serialized bytes) โ
โ โ โ
โ โผ โ
โ Deserializer reads type names from payload โ
โ โ โ
โ โผ โ
โ Constructs object graph โ
โ โ โ
โ โผ โ
โ readObject / __destruct / readResolve runs โ
โ โ โ
โ โผ โ
โ Chain: A โ B โ C โ sink (exec) โ
โ โ โ
โ โผ โ
โ Arbitrary code execution โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Trust Boundary
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TRUSTED UNTRUSTED โ
โ โ
โ In-process objects Network input โ
โ Internal cache Queue messages โ
โ Signed data User uploads โ
โ โ
โ Native serialization OK Native serialization NEVER โ
โ (if both ends trusted) (use data-only format) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Mitigation Layers
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ LAYER 1: AVOID โ
โ JSON, safe YAML, explicit types โ
โ โ โ
โ โผ โ
โ LAYER 2: ALLOWLIST โ
โ Serialization filter with expected classes โ
โ โ โ
โ โผ โ
โ LAYER 3: BOUND โ
โ Graph depth, array length, stream size โ
โ โ โ
โ โผ โ
โ LAYER 4: HARDEN โ
โ readObject throws, transient fields โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Patch Trap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ CHAIN PUBLISHED โ
โ โ โ
โ โผ โ
โ Maintainer patches one gadget โ
โ โ โ
โ โผ โ
โ Attacker reuses surviving gadgets โ
โ โ โ
โ โผ โ
โ New chain works โ
โ โ
โ Fix: patch the primitives, not the published exploit โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Vulnerability class | CWE-502 (Deserialization of Untrusted Data) |
| Mechanism | Gadget chain of legitimate classes reaching a sink |
| Java sink | ObjectInputStream.readObject() |
| .NET sink | BinaryFormatter, TypeNameHandling โ None |
| Python sink | pickle.loads, yaml.load |
| PHP sink | unserialize() |
| Ruby sink | Marshal.load |
| Primary fix | Data-only formats with explicit types |
| Secondary fix | Allowlist filter with resource limits |
| Patch rule | Patch primitives, not published chains |
Key takeaways:
- The payload chooses the type, and the type is the attack. A deserializer that constructs whatever class the bytes name gives the attacker control over object construction. The fix is to fix the type in code, not let the data decide .
- Gadget chains turn construction into execution. The chain is a sequence of legitimate classes already on the classpath. The attacker does not upload code; they arrange calls. Java’s CommonsCollections, .NET’s TypeConfuseDelegate, PHP’s
__destructchains, and Ruby’s ERB chains are all variations of the same mechanism . - Every language has a risky call and a safe alternative. Java has
ObjectInputStreamand JSON with fixed types. .NET hasBinaryFormatterandSystem.Text.Json. Python haspickleandjson. PHP hasunserializeandjson_decode. - Allowlists are stronger than blocklists. A blocklist of known-bad classes is always incomplete because new chains are discovered. An allowlist names what the application needs and rejects everything else .
- Filters do not make untrusted deserialization safe. OWASP is explicit: filtering reduces risk but does not eliminate it. The primary control is to avoid native serialization for untrusted data entirely .
Remember: Deserialization is the moment bytes become objects, and objects can execute code. The vulnerability is not in the format; it is in the deserializer’s willingness to construct types the payload names. Use data-only formats for untrusted input, fix the target type in code, and if native serialization is unavoidable, put an allowlist filter with resource limits in front of it. Patch the primitives, not the published exploitโan attacker who finds one broken link will replace it with a surviving one.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!