Laravel8 CVE-2021-3129 复现分析
2021-6-3
| 2024-2-2
0  |  阅读时长 0 分钟
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方便
notion image

2.3 编写漏洞入口代码

先在 resources/views 目录下新建一个文件 hello.blade.php,内容为
routes/web.php 增加路由
notion image
访问这个视图
正常出现变量未定义报错信息
notion image

3. 漏洞分析

3.1 触发点路由追踪

点击 Make variable optional 并抓包,这个方法会自动把我们模板的 {{ $username }} 替换为 {{ $username ?? '' }},即添加一个默认值
notion image
可以看到,请求的路径为 _ignition/execute-solution
notion image
全局分别搜一下 _ignition 和 execute-solution 可知
本API对应的方法为 vendor/facade/ignition/src/IgnitionServiceProvider.php 中的 ExecuteSolutionController 类
notion image
跟进此类,下个断点发现ExecuteSolutionController类对象会被当做函数调用
notion image
与其定义的 __invoke 魔术方法一致。
__invoke 魔术方法中先调用
获取 $solution 对象随后调用 $solution对象的 run方法
跟进得知 $solution 即为我们 POST 传入的
跟进 $solution->run();
notion image
逻辑可简化为
其中 $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 级别的报错日志。
notion image
格式可分为 4 部分,时间、错误、错误描述、错误堆栈
先尝试一下输入的内容是否能正常的写入日志文件中
notion image
notion image
可以正常写入,也就是,日志文件大概如下结构,其中payload为我们可控部分
问题在于,如何在存在[prefix] [midfix1] [midfix2] [suffix] 的情况下,如何让可控PAYLOAD被利用?
 
首先,考虑 phar://数据流包装器,因为 file_get_contents 会触发 phar 的反序列化,通过 viewFile 传入即可。但这里会遇到几个问题

3.3 尝试 base64 过滤器修改内容

PHP 中 base64 解码会过滤除特殊字符,如
notion image
'1' 等价于 base64_decode('MQ==') 等价于 base64_decode('[;#MQ==#...<<>')
 
然后PHP的base64_decode解码还有一个特性,在需要解码的字符串长度不足4的倍数时,会自动添加 = 号填充,也就是说,没有因输入的字符串不符合base64编码后的规则而抛出异常一说,直至把字符串解码为空串
notion image
但问题在于,这个值难以控制,如日志里的时间
稍微把5变为6,结果则不尽相同。此外,后面还有调用栈等复杂的字符串,处理起来比较麻烦。
 
不过PHP base64特性也不是一无是处,我们可以利用它清空laravel.log使我们受到的干扰进一步降低。经实验,258278字符的日志文件在经过 8次 base64_decode后即可变为空。即多请求几次这个接口即可清空日志文件。
理想中是美好的,但意外却发生了
notion image
上图中报错,是因为日志文件中含有 = 号,但等号在 base64编码中是结束用的填充符号,会使得PHP base64解码过滤器抛出异常[5]
 
原来,filter过滤器中的base64解码和直接用 base64_decode是不同的。3.3小节开头中用base64_decode函数字符串中含有 = 号是能正常解码的。
 
但假设不存在 = 号,清空文件是无问题的哈
notion image
 
或者一步到位,结合多个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 部分。赶紧试试,太骚了这思路。
notion image
发现写不进去。。仔细想想,\0 在PHP是阶段,会报错,也难怪,而且到时候 file_get_contets() 的时候,也会报错。
 
这里又需利用到另外一个过滤器
我们可以先把 \0 编码为 =00,读取的时候解码即可。
先写入PAYLOAD,这里为
notion image
然后写入,先解码 =00 然后在把utf16转换为utf8
notion image
又报错了,utf16要求双字节对齐,但日志文件来说,不一定是双字节对齐。处理方法很简单,任何数*2都是偶数,就双字节对齐啦,如变成下面这样
赶紧尝试一下 (
不出意外,又报错了
notion image
找了半天,原来PAYLOAD被部分截断了,然后截断的位置非常神奇,并且加了个点 =0.,导致解码错误,
notion image
写个小demo,果然如此
notion image
即我们的部分PAYLOAD导致出了问题
这个容易解决,查看日志文件,数一下发现只会截断前15个,我们这里填充个100个随意字符,这里选择填充A,怎么也不会殃及到我们的PAYLOAD
也就是说,我们现在不用关心部分PAYLOAD了,现变为
先清空日志,然后发两次
notion image
转换
notion image
notion image
 
nice~
特殊字符用base64编码去特殊字符就行,当然我们的 tari 没有进行base64编码,所以直接base64解码,特殊字符是去掉了,不过我们的 tari 也会被解码为乱码
看到了熟悉的有问题的子串 ==00 号,就和我们日志里出现的 =0. 类似,都会有问题
 
这里有两个方法
  1. 编码 =,即编码为 =3D
  1. 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]部分是一样的,而且是不会变的。
notion image
notion image
首先看看整体是不是双字节对齐的
 
==证明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.ERROR
midfix1 长度为 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 整合

  1. 清空日志
notion image
 
  1. 写入填充数据
notion image
 
  1. 写入我们可控数据
这里Payload字段比较短,为双奇数型,不需要处理
notion image
如果比较长,比如真实漏洞利用,一般为前奇后偶型,需要在PAYLOAD后缀加上 =00 保证对齐
4. 转换去除无关数据
notion image
OK,终于可控了~
notion image
 

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反序列化
注意这里的路由必须为绝对路径(相对路径试过了会报错)
 
不过绝对路径也是已知的,可以通过报错信息获取~
notion image
 

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等)做了巩固和更深入的理解和应用。
 

参考链接

  • 漏洞复现
  • 2021强网杯 Web WriteupWeb writeup | 2021年第一届广东大学生网络安全攻防大赛-晋级赛
    • GitTalk
    目录