Skip to content
All posts
webrevised February 24, 20268 min read

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.

LanguageLooks likeBase64 begins
PHPO:4:"User":2:{s:8:"username";s:6:"vickie";...}Tzo (for O:) or YTo (for a:)
JavaBinary starting AC ED 00 05rO0AB
Python pickleBinary with \x80\x04 or \x80\x05 then opcodes; readable class namesgASV / gAWV / gAJ
.NET BinaryFormatterBinary starting 00 01 00 00 00 FF FF FF FFAAEAAAD
.NET / Java XML<Object type= or <java version= in plain XMLn/a - usually not encoded
Node (node-serialize)JSON with a value like "_$$ND_FUNC$$_function(){...}"n/a
Ruby MarshalBinary starting 04 08BAg
Any opaque cookie is worth one base64 decode and one hexdump. The cost is ten seconds and the answer is usually immediate.

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 too
The length prefixes are not decoration. Change a string and forget its count and unserialize() returns false, which usually surfaces as a blank page rather than a useful error.

Two 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.

LanguageHookWhen 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
JavareadObject()During ObjectInputStream.readObject()
JavareadResolve(), finalize()After construction, and at collection
Python__reduce__()The pickle protocol *asks* the object how to rebuild itself, and does what it says
.NETOnDeserialized, IDeserializationCallbackAfter 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())
os.system is the loud version. In a CTF where you cannot see stdout, return a subprocess call that curls a callback, or one that reads the flag into a URL.

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));
Redeclaring the classes locally is the standard way to emit the payload: the serialized form only carries class names and property values, so your stub only needs matching names.

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(' .
Three greps, in that order. The chain lives between the second list and the third, and is usually short enough to find by reading.

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:

  1. Out-of-band. Make the chain issue a DNS lookup or an HTTP request to a host you control. A URLDNS chain 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.
  2. Timing. A chain that sleeps is a chain that ran. Ten seconds of difference is unambiguous even through a load balancer.
  3. 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

  1. Base64-decode every opaque token and check it against the signature table.
  2. Try editing values first - fix the length prefixes - in case the app is trusting the blob as authentication.
  3. Identify the language's deserialization hooks; for Python that is __reduce__ and you are already done.
  4. With source, grep for deserializers, then magic methods, then sinks, and read the space between.
  5. Without source, fingerprint the dependencies and try the published chains for them.
  6. If nothing comes back, prove execution out-of-band before building anything elaborate.