type
status
date
slug
summary
tags
category
icon
password
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等)做了巩固和更深入的理解和应用。