Reading serialized blobs, and the gadget chains hiding behind them
How to recognise PHP, Java, Python, .NET, and Node serialized data on sight, edit it by hand when that is enough, and build a property-oriented chain out of the target's own classes when it is not.
Serialization exists because programs need to put an object somewhere that is not memory - a cookie, a cache, a queue, a file - and get it back later. Deserialization is the return trip, and its defining property is that the blob decides what gets constructed. Not the developer. Not a schema. The bytes.
That is the whole vulnerability, and it explains why deserialization bugs behave so differently from every other injection class. There is no payload that works everywhere, because you are not injecting *code* - you are choosing which of the target's own classes get instantiated, and with which properties. The exploit is assembled out of the application's dependencies, which is why the same primitive is trivial against one target and impossible against another.
Recognising the blob
Before anything else you need to notice that a cookie, a hidden form field, or a POST body is serialized data rather than a random token. Each language has a signature that is unmistakable once you have seen it, and the outer layer is very often base64, so decode first and look second.
| Language | Looks like | Base64 begins |
|---|---|---|
| PHP | O:4:"User":2:{s:8:"username";s:6:"vickie";...} | Tzo (for O:) or YTo (for a:) |
| Java | Binary starting AC ED 00 05 | rO0AB |
| Python pickle | Binary with \x80\x04 or \x80\x05 then opcodes; readable class names | gASV / gAWV / gAJ |
| .NET BinaryFormatter | Binary starting 00 01 00 00 00 FF FF FF FF | AAEAAAD |
| .NET / Java XML | <Object type= or <java version= in plain XML | n/a - usually not encoded |
| Node (node-serialize) | JSON with a value like "_$$ND_FUNC$$_function(){...}" | n/a |
| Ruby Marshal | Binary starting 04 08 | BAg |
Level one: just edit the values
A surprising number of challenges stop here, and it is worth trying before anything clever. PHP's format is human-readable and therefore hand-editable: data type:length:value, with s for string, i for integer, b for boolean, a for array, and O for an object instance.
O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:9:"not admin";}
^^^^^^^^^^^^
O:4:"User":2:{s:8:"username";s:6:"vickie";s:6:"status";s:5:"admin";}
^ the length
must change tooTwo details that cost people time. Private properties are serialized with null bytes around the class name (\0ClassName\0prop) and protected ones with \0*\0prop; those null bytes are real bytes and they must survive whatever transport you are using, which usually means URL-encoding them as %00. And the property count at the front of the object has to match the number of properties you actually supply.
The equivalent at this level in other languages: a Java or .NET blob is binary and not worth hand-editing, but a Node node-serialize payload is JSON you can retype, and a Python pickle can be regenerated in three lines. If the application is using serialization as a substitute for authentication - trusting is_admin because it came back in a cookie it once issued - then editing the value is the entire challenge.
Level two: the methods that run themselves
The escalation from tampering to code execution turns on one fact: deserialization is not a passive copy. Rebuilding an object runs code, because objects need to be reinitialised, and languages provide hooks for that. Those hooks fire before the application has looked at the object, which is what makes them reachable.
| Language | Hook | When it fires |
|---|---|---|
| PHP | __wakeup() | During unserialize(), as the object is reconstructed |
| PHP | __destruct() | When the object is garbage collected - always, eventually |
| PHP | __toString() | When the object is used in a string context, e.g. echoed or concatenated |
| PHP | __call() | When an undefined method is invoked on it |
| Java | readObject() | During ObjectInputStream.readObject() |
| Java | readResolve(), finalize() | After construction, and at collection |
| Python | __reduce__() | The pickle protocol *asks* the object how to rebuild itself, and does what it says |
| .NET | OnDeserialized, IDeserializationCallback | After the graph is materialised |
Python is the outlier and deserves its own paragraph, because pickle is not a data format with an unfortunate hook - it is a stack virtual machine with an opcode for calling arbitrary callables. __reduce__ returns a callable and its arguments, and the unpickler invokes them. There is no bug to find. Handing untrusted data to pickle.loads is remote code execution by design, which is why the Python version of this challenge is often only four lines long.
import pickle, base64
class RCE:
def __reduce__(self):
import os
return (os.system, ("id",))
print(base64.b64encode(pickle.dumps(RCE())).decode())Pickle also has a restricted mode that challenges like to build on. pickle.Unpickler with an overridden find_class allowlists which globals may be resolved, and the challenge becomes: given only these classes, find a path to execution. That is exactly the gadget-chain problem below, in miniature.
Level three: POP chains
Now the interesting case. You control an object being deserialized, but no class with a magic method does anything useful on its own. The technique is a property-oriented programming chain, and the name is precise: you control every property of every object you construct, so you can wire the application's own objects together into a call sequence its author never wrote.
The structure is always the same. A magic method is the entry point - it is the only code that runs without the application choosing to run it. That method does something with a property. You set that property to another object, chosen so that the something-done-to-it is a method that does something else useful. Repeat until you reach a sink.
<?php
// In the target's codebase:
class Example {
private $obj;
function __wakeup() { return $this->obj->evaluate(); } // entry point
}
class CodeSnippet {
private $code;
function evaluate() { eval($this->code); } // sink
}
// Neither class is dangerous. Wiring them together is.
class CodeSnippet { private $code = "system('id');"; }
class Example { private $obj;
function __construct() { $this->obj = new CodeSnippet; } }
print urlencode(serialize(new Example));That is a two-link chain. Real ones are longer, and their length is a consequence of the sink being far from any magic method. A published Java chain might pass through a comparator, a lazy map, a transformer array, and reflection before it reaches Runtime.exec, and each link exists only because the previous link's data flows into it.
Chains do not have to end in command execution. A chain that ends in a file write can drop a webshell; one that ends in a file_get_contents on a controlled path is a file read and an SSRF at once; one that ends in a SQL query with a controlled table name is an injection. In a CTF, look at what the flag actually requires before assuming you need system.
Finding the chain
If the challenge gives you source - and web challenges usually do - reading it is far faster than guessing. Work backwards from sinks, not forwards from entry points, because sinks are rare and entry points are everywhere.
# 1. Where does untrusted data enter a deserializer?
grep -rn 'unserialize\|pickle.loads\|yaml.load\|readObject\|BinaryFormatter\|Marshal.load' .
# 2. What magic methods exist at all? These are your entry points.
grep -rn '__wakeup\|__destruct\|__toString\|__call\|readObject\|__reduce__' .
# 3. What sinks exist? These are your destinations.
grep -rn 'eval(\|system(\|exec(\|popen(\|file_put_contents\|include(\|require(' .When there is no source - a compiled Java service, a .NET endpoint - the chain comes from the dependencies instead, and those are public. ysoserial for Java and ysoserial.net for .NET carry the published chains, each named for the library it needs: CommonsCollections, Spring, Groovy, JSON.Net, TypeConfuseDelegate. The task becomes fingerprinting which libraries the target ships and trying the chains that match.
The blind case
Deserialization exploits often produce no output at all. The object is rebuilt, your chain fires, and the application returns the same page it always did. Three ways to get a signal:
- Out-of-band. Make the chain issue a DNS lookup or an HTTP request to a host you control. A
URLDNSchain in Java does exactly this and needs no third-party library at all, which makes it the ideal first probe: it proves the deserializer is live before you spend time on a chain that needs a specific dependency. - Timing. A chain that sleeps is a chain that ran. Ten seconds of difference is unambiguous even through a load balancer.
- Errors. A malformed blob that throws a different exception from a well-formed one tells you the parser reached a different depth. Walk the length prefixes deliberately and read the error text.
The short version
- Base64-decode every opaque token and check it against the signature table.
- Try editing values first - fix the length prefixes - in case the app is trusting the blob as authentication.
- Identify the language's deserialization hooks; for Python that is
__reduce__and you are already done. - With source, grep for deserializers, then magic methods, then sinks, and read the space between.
- Without source, fingerprint the dependencies and try the published chains for them.
- If nothing comes back, prove execution out-of-band before building anything elaborate.