1. 前言
由于参加两次CTF都遇上了
反序列化+PHP各种filter过滤器的利用,然后对一些不常见filter的利用不是很熟悉,因此复现分析一下 CVE-2021-3129,因为这个CVE对一些filter利用的比较巧妙ignition组件是Laravel5.5及其之后的使用的自定义错误页面美化组件,2.5.2 版本之前因
file_get_contents() 和 file_put_contents()两个函数不安全使用导致攻击者可以在Laravel框架(<8.4.2)开启debug模式的情况下未授权RCE。影响范围[1-2]
- ignition
>= 2.5.0, < 2.5.2
>= 2.0.0, < 2.4.2
>= 1.7.0, < 1.16.14
< 1.6.15
- Laravel
< 8.4.2
2. 环境搭建
复现环境:PHP 7.3.24
2.1 安装 composer
2.2 安装 Laravel
.env.example 默认配置是debug模式,可不予理会然后启动 Apache 环境即可,访问
这里不用
php artisan serve 启动因为没有Apache集成环境debug方便
2.3 编写漏洞入口代码
先在
resources/views 目录下新建一个文件 hello.blade.php,内容为routes/web.php 增加路由
访问这个视图
正常出现变量未定义报错信息

3. 漏洞分析
3.1 触发点路由追踪
点击
Make variable optional 并抓包,这个方法会自动把我们模板的 {{ $username }} 替换为 {{ $username ?? '' }},即添加一个默认值
可以看到,请求的路径为
_ignition/execute-solution
全局分别搜一下
_ignition 和 execute-solution 可知本API对应的方法为
vendor/facade/ignition/src/IgnitionServiceProvider.php 中的 ExecuteSolutionController 类
跟进此类,下个断点发现
ExecuteSolutionController类对象会被当做函数调用
与其定义的
__invoke 魔术方法一致。在
__invoke 魔术方法中先调用获取
$solution 对象随后调用 $solution对象的 run方法跟进得知
$solution 即为我们 POST 传入的跟进
$solution->run();
逻辑可简化为
其中
$parameters 为我们 POST 传入的之所以可以简化为两行,是因为中间的逻辑主要为,在
$this->makeOptional($parameters);方法里把 $parameters['variableName']加上 "?? ''" ,之后为从词法分析层面,即 $this->generateExpectedTokens() 中,是否真的在变量 $parameters['variableName'] 期望位置加上了 "?? ''",加上了的话就返回替换后的内容。所以只要我们传入的
$parameters['variableName'] 变量在模板文件中真实存在,多为满足条件的。当然这是建立在我们只到变量名称的情况下,如果不知道呢?换个角度理解,只要我们传入的变量是模板中不存在的,那么
$newTokens 恒为 [0],$expectedTokens 也是恒为 [0],那么 $expectedTokens !== $newTokens 恒成立,压根就不用管这部分逻辑。3.2 漏洞点分析
file_get_contents 和 file_put_contents 读取和写入的文件是一样的,且文件输出内容几乎不可控,相当于读取一个文件,又写回去,就加上了 "?? ''" 。虽然
$parameters['variableName'] 不太可控,但 file_get_contents 和 file_put_contents 的输入,即$parameters['viewFile'] 是可控的。然后服务端上我们可控的文件只有日志文件,这里使用 Laravel 的日志文件,默认位置是 storage/logs/laravel.log ,是用来记录一些 ERROR 级别的报错日志。
格式可分为 4 部分,时间、错误、错误描述、错误堆栈
先尝试一下输入的内容是否能正常的写入日志文件中


可以正常写入,也就是,日志文件大概如下结构,其中payload为我们可控部分
问题在于,如何在存在
[prefix] [midfix1] [midfix2] [suffix] 的情况下,如何让可控PAYLOAD被利用?首先,考虑
phar://数据流包装器,因为 file_get_contents 会触发 phar 的反序列化,通过 viewFile 传入即可。但这里会遇到几个问题3.3 尝试 base64 过滤器修改内容
PHP 中
base64 解码会过滤除特殊字符,如
'1' 等价于 base64_decode('MQ==') 等价于 base64_decode('[;#MQ==#...<<>')然后PHP的
base64_decode解码还有一个特性,在需要解码的字符串长度不足4的倍数时,会自动添加 = 号填充,也就是说,没有因输入的字符串不符合base64编码后的规则而抛出异常一说,直至把字符串解码为空串
但问题在于,这个值难以控制,如日志里的时间
稍微把5变为6,结果则不尽相同。此外,后面还有调用栈等复杂的字符串,处理起来比较麻烦。
不过PHP base64特性也不是一无是处,我们可以利用它清空
laravel.log使我们受到的干扰进一步降低。经实验,258278字符的日志文件在经过 8次 base64_decode后即可变为空。即多请求几次这个接口即可清空日志文件。理想中是美好的,但意外却发生了

上图中报错,是因为日志文件中含有
= 号,但等号在 base64编码中是结束用的填充符号,会使得PHP base64解码过滤器抛出异常[5]。原来,filter过滤器中的base64解码和直接用
base64_decode是不同的。3.3小节开头中用base64_decode函数字符串中含有 = 号是能正常解码的。但假设不存在
= 号,清空文件是无问题的哈
或者一步到位,结合多个base64解码过滤器
最终发现base64过滤器各种直接用不上,但问题不大,这里记得他的一个特性,如果不存在
= 号,他可以帮助我们去掉非base64编码后的字符。3.4 考虑其他 filter 过滤器
清空文件除了使用 base64-decode过滤器,也可以使用,而且不会受到特殊符号的影响
Emm,不过就是不知道这个
filter 是如何发现的。。网上找了半天没找到至此,上面的问题还是没有解决
问题在于,如何在存在
[prefix] [midfix1] [midfix2] [suffix] 的情况下,如何让可控PAYLOAD被利用?来硬的不行,换个思路,假设可以让
[prefix] [midfix1] [midfix2] [suffix]转换为不可见字符,保留 PAYLOAD 部分为可见字符,然后结合PHP base64 解码的特性,是不是就可以只保留 PAYLOAD 部分了?好像还真可以,利用
filter过滤器把日志内容识别为 UTF-16 编码,并转换为 UTF-8编码,因为 UTF-16 是双字节编码,那么原有的 [prefix] 是否就全变为不可见字符?然后
PAYLOAD 部分,输入 P\0A\0Y\0L\0O\0A\0D\0 ,写入时指定 utf16le -> utf-8转换为 UTF-8 刚刚好为
PAYLAOD,最后在 base64解码一下,刚刚好只剩下 PAYLOAD 部分。赶紧试试,太骚了这思路。
发现写不进去。。仔细想想,
\0 在PHP是阶段,会报错,也难怪,而且到时候 file_get_contets() 的时候,也会报错。这里又需利用到另外一个过滤器
我们可以先把
\0 编码为 =00,读取的时候解码即可。先写入PAYLOAD,这里为

然后写入,先解码
=00 然后在把utf16转换为utf8
又报错了,utf16要求双字节对齐,但日志文件来说,不一定是双字节对齐。处理方法很简单,任何数*2都是偶数,就双字节对齐啦,如变成下面这样
赶紧尝试一下 (
不出意外,又报错了

找了半天,原来
PAYLOAD被部分截断了,然后截断的位置非常神奇,并且加了个点 =0.,导致解码错误,
写个小demo,果然如此

即我们的
部分PAYLOAD导致出了问题这个容易解决,查看日志文件,数一下发现只会截断前15个,我们这里填充个100个随意字符,这里选择填充
A,怎么也不会殃及到我们的PAYLOAD也就是说,我们现在不用关心
部分PAYLOAD了,现变为先清空日志,然后发两次

转换


nice~
特殊字符用base64编码去特殊字符就行,当然我们的
tari 没有进行base64编码,所以直接base64解码,特殊字符是去掉了,不过我们的 tari 也会被解码为乱码看到了熟悉的有问题的子串
==00 号,就和我们日志里出现的 =0. 类似,都会有问题这里有两个方法
- 编码
=,即编码为=3D
- base64编码后,把
=号去掉,因为PHP的base64解码发现位数不为4的整数倍,会自动在后方加=号,这点在3.3小节说过
这里选择第2种方法
3.5 两个PAYLOAD和双字节对齐
但是这样还有个问题,就是我们的
PAYLOAD 出现了两次,处理方法很简单,把原来的转换为
其中
PAYLOAD_1随意即可,无需为真的PAYLOAD,比如我们可以把 PAYLOAD_1 变为保持为100个 'A' ,因为没有 \0 ,所以从utf16转换为utf-8会把他转换为”乱码”到这里可能会有所疑问,我们的
PAYLOAD_2 不是也出现了两次么?这里也挺巧妙的,一开始我们是清空了日志的,这时我们的日志文件
laravel.log 文件为空。PAYLOAD_1 和 PAYLOAD_2 前面都填充了 100个 A,所以 [midfix1][midfix2][suffix]部分是一样的,而且是不会变的。

首先看看整体是不是双字节对齐的
==证明1:==
PAYLOAD_1是偶数,已对齐;
- 2个
[prefix][midfix1][midfix2][suffix]是一样的,无论奇偶与否,乘以2都是偶数,对齐;
PAYLOAD_2也是有两个,对齐;
所以整体肯定是对齐的。不然过滤器
convert.iconv.utf-16le.utf-8 无法正常工作。==证明1证毕==
接下来考虑顺序问题,会不会因
奇数+P\0A\0 刚好把我们精心构造的PAYLOAD给破坏了呢?我们发的第一个填充包,整体可能是奇数,也可能是偶数,,接下来分两种情况分别讨论
- 前提
[prefix] 长度为 53,为奇数,这里应该都是一样的,尝试过使用内网其他机器访问,也是 local.ERRORmidfix1 长度为 119,为奇数设定 Si 表示串下标为 i 之前的子串
- 当第1个填充包为奇数
由证明1知,我们发的2个包肯定是双字节对齐,也就是偶数,那么此时第2个填充包肯定也为奇数
= 第1个填充包(奇数) + 第二个包的
[prefix]部分(奇数) -> 偶数此时没有破坏
第1个PAYLOAD_2然后
[midfix2]的长度 是奇数,此时 + P\0A\0 肯定会破坏第2个PAYLOAD_2,也就是说这部分经转换为乱码因为此前都是偶数,
[midfix2] 到最后肯定也是偶数,不会破坏双字节对齐最终输出的为,
base64_decode(乱码+第1个PAYLOAD_2+乱码),就仅有 PAYLOAD_2因此这种情况最终只有1个我们构造的
PAYLOAD,符合条件- 当第一个填充包为偶数
由证明1知,我们发的2个包肯定是双字节对齐,也就是偶数,那么此时第2个填充包肯定也为偶数
= 第1个填充包(偶数) + 第二个包的
[prefix]部分(奇数) -> 奇数, (奇数) +
P\0A\0 肯定会破坏第1个PAYLOAD_2,这部分都为乱码,不用理会此时 = (奇数) +
第1个PAYLOAD_2(偶数) -> 奇数然后 = (奇数) +
[midfix2] (奇数) -> 偶数(偶数)并不会破坏
第二个PAYLOAD_2 的结构,(偶数)+
第二个PAYLOAD_2 (偶数) -> 偶数,所以后面也为偶数,不会破坏双字节对齐最终输出的为,
base64_decode(乱码+第2个PAYLOAD_2+乱码),就仅有 PAYLOAD_2因此这种情况最终只有1个我们构造的
PAYLOAD,符合条件- 综上
下面这种构造方法
[prefix] 和 [midfix1] 都为奇数, 且 PAYLOAD_1 为长度偶数,最终可以安全的得到一个 PAYLOAD_2- 另外一种情形
因为包的
[prefix] 和 [midfix1] 部分刚好都为奇数,所以是这样。如果是其他情况呢,假设
[prefix] 和 [midfix1] 是前奇后偶的情况?这个和实际利用情况很像,因为如果file_get_contents 输入的参数过长,
[midfix1] 会变成长度是 110
就需要在我们想要的PAYLOAD最后填充
=00,就可以了,因为这样无论第1个或第2个PAYLOAD前面是奇数还是偶数,首先双字节一定是对齐的,然后第1个PAYLOAD前是奇数,会吃掉第1个PAYLOAD,又因PAYLOAD后有一个=00 会吃掉后面一个字符,这样就变成了偶数,所以第二个不会被吃,反则反之。所以前奇后偶的情况是这样
因为第1个payload前只有奇数或偶数两种情况,所以考虑这两种情况足以。
那么针对这两种情况,有没有通用的解决方法呢?(我暂没发现。。
3.6 整合
- 清空日志

- 写入填充数据

- 写入我们可控数据
这里Payload字段比较短,为双奇数型,不需要处理

如果比较长,比如真实漏洞利用,一般为前奇后偶型,需要在
PAYLOAD后缀加上 =00 保证对齐
4. 转换去除无关数据

OK,终于可控了~

4. 漏洞利用
4.1 手工利用
4.1.1 清空日志
4.1.2 写入填充对齐数据
4.1.3 生成并写入 phar payload
4.1.3.1 生成phar payload
4.1.3.2 写入phar payload 数据 (4.1.3.1 生成的)
因为payload比较长,所以是前奇后偶型,注意别忘了在最后还要补上一个
=00 这里已经补上了4.1.4 把日志文件转换为仅剩phar数据
4.1.5 通过file_get_contents触发phar反序列化
注意这里的路由必须为绝对路径(相对路径试过了会报错)
不过绝对路径也是已知的,可以通过报错信息获取~

4.2 脚本利用
改进了一下原有脚本,可以兼容laravel的
artisan serv 和 apache代理(路由后面少个 /)启动模式测试PHP版本为 PHP7.3.24
PHP 8 用不了,可能是因为 monolog 的gadget有问题
总结
本次漏洞踩了的坑很多
- 在 base64过滤器里,理解了挺久一些字符串还原特性[5]的,然后清空中过滤器与
base64_decode函数不同的坑也踩了,原来过滤器中,中间不能用=号。顺便深入理解了baes64编码的原理,解码的条件。
- 了解了PHP伪协议一些不太常见的过滤器,原来还可以联合起来,配合报错日志这样玩,,这样构造仅有我们想要的内容
- 在字节对齐这理解了很久,也是有
=号产生的坑,做了很多不同实验踩才理解透彻
虽然 虽然漏洞点不在很深的位置,但是不明显,
file_get_contents 和 file_put_contents 都绑定了,当时打ctf遇到这个确实没啥好的思路。虽然花了几天复现这个漏洞,不过学了很多新的东西,也对旧的知识(base64、phar等)做了巩固和更深入的理解和应用。