前言:
最近开始审计thinkphp的一些漏洞,理解其原理,加深对框架工作模式的认知。由于在CTF中了解一些反序列化的基本知识以及基本原理,但是没有分析过一条真正意义上的反序列化链,这次直接上手thinkphp的反序列化链开头也比较困难,不过在用了差不多一周的时间反反复复研究也差不多看懂了这条链,在学习网上大师父们的思路后自己也有了一些新的理解,遂记录。
复现环境:
thinkphp5.1.37,windows10
0X01:基础配置
总所周知反序列化漏洞的触发的基础来自于php自带的魔法函数,这一类函数他们非常具有特点。特点就在于当对象被执行特定操作时就会触发。在本次pop链中使用到的魔法函数有
1 | __construct()在对象被实例化时调用 |
要开始pop链的分析,必须要添加一个入口,unserialize()函数会将我们的序列化数据还原,从而开始pop链的执行。我在index模块的index控制器下增加了如下代码
用一个payload参数来接收我们的序列化数据,这里将payload先加密在解密的原因是因为直接传输需要进行url编码,这样处理后就不用url编码了,很方便。
至此,我们打通了一个能够进行反序列化的通道,开始pop链的分析之旅
0X02:入口
Payload:
为了更方便我们进行整个链的分析,我将payload先放出来,以方便观察利用链中的可控参数变化。
1 | <?php |
Go!
construct()和destruct()永远是我们优先考虑的对象,因为他们是必然触发的。在对象被实例化的时候触发construct(),在对象被销毁的时候调用destruct()。
而本次序列化的入口来自thinkphp/library/think/proccess/pipes/Windows.php,在代码的第56行有一个destruct函数
在windows类被销毁时会调用两个方法,close(),removeFiles()
0X03:POP链上的任意文件删除
跟进close()没有发现往下走的可能
跟进removeFiles()我们发现代码是这样子的
这个地方首先遍历了一下$this->files,这里的$this->files是我们可控的,那么$filename也是我们可控的。然后此处就存在任意文件删除,只要我控制$this->files为文件路径,那么就可以删除这个文件。我们进行尝试
1 | <?php |
将payload打过去过后对应目录下的1.txt消失,任意文件删除成功。
这只是在审计这个链开头遇到的一个小意外,我们还没开始进入真正的pop链分析呢
0X04:整个链的分析
回到刚才,在文件删除的时候会先调用file_exists来判断文件是否存在,而正是这个函数给我我们提供了往下走的可能。
查看官方文档对file_exists函数的描述:
官方文档很明确的告诉我们,file_exists需要的是一个字符串类型的数据,也就是说file_exists会将传入他的数据当作字符串进行解析。到这我们自然便会联想到__toString()魔术方法,当一个对象被当作字符串解析的时候便会调用toString()那么,而这里的filename又是我们完全可控的,所以我们就能够通过控制filename的值为一个对象来实现调用任意类的toString()方法。
全局搜索__toString()方法,在Conversion.php中发现toString方法中调用了toJson(),可是我们这个Conversion类是一个trait
什么是trait呢?trait是php的一个特性,简单的来说,可以通过use关键字来组合不同的trait以解决PHP无法同时从两个基类中继承属性和方法的问题,trait是不可被实例化的。
要想出发到Conversion.php中的toString方法,我们必须要找到use了他的类,经过我们寻找,
在Model.php中一个抽象类Model中use了Conversion可是Model类是一个抽象类呀,抽象类是不可以被实例化的。所以我们继续寻找继承了Model类的子类,终于,我们在Pivot.php中找到了一个Pivot类,它继承自Model类
现在类找到了,只要我们将$filename变为Pivot类的实例化对象,那么就会运行到Conversion的toString()方法,达到我们的目的。
现在我们接着分析Conversion。
来到Conversion的toString方法如下:
调用了toJson()方法,我们继续跟进toJson方法:
toJson方法调用了toArray方法,继续跟进,toArray是我们重点分析对象,该方法代码块内容较长,只列出了重点分析的段落,代码如下:
1 | public function toArray() |
可以看到在这个地方我在多出打印出了key,name,relation的值,他们在整个利用链中至关重要,也在无时无刻发生变化,需要重点关注。接下来我们来进行分析。
1.toArray()中的$this->append
首先,一开始就对$this->append进行了循环,$this->append是我们在payload中的可控参数,他的值为[“Mengd@”=>[‘bcc’, ‘bcc’]]他是一个拥有一个键名为Mengd@键值为[‘bcc’,’bcc’]数组的二维数组。经过第一个foreach后$key的值为Mengd@,$name的值为[‘bcc’,’bcc’]。
2.toArray()中的$this->getRelation($key)
在刚刚从$this->append中取出$key为Mengd@后,再经过一次if判断$name是否为数组:
紧接着便带着这个数据进入了,$this->getRelation($key),这里面的代码为:
可以看见,传入的$key不为空,所以getRelation中的$name也不为空,if条件中为假,由于relation此时没有值,elseif也无法触发,直接return返回null,此时,$relation为null
3.toArray()中的$this->getAttr($key)
刚刚经过经过了getReation(),此时的$relation为空,紧接着便调用了$relation=$this->getAttr($key),代码如下:
此段代码也很长,我将其简化成如下格式,在尝试执行$value=$this->$this->getData($name)(此时的$name等于传入的$key等于Mengd@)后,经过一些列省略号的操作便返回了$value,那其中$this->data()变成了我们主要关注的对象,我们跟进getData($name)一探究竟。
1 | public function getAttr($name, &$item = null) |
4.Attribute中的getData
刚刚调用到getData($name),代码如下:
此时$name为Mengd@,$this->data=[“Mengd@”=>new Request()],触发第二个if语句,直接返回$this->data[$name],
那么$value=$this->data[$name],因为$this->data,$name都是我们可控的,所以$value可控进而$relation可控,此时$relation的值为Request()对象。
5.寻找RCE的足迹
刚刚提到我们可以通过控制$this->data,$name进而起到控制$relation的作用。紧接着我们就要调用$relation->visible($name)方法了,前面提到了__call魔术方法,当对象调用不存在的方法的时候会自动调用,这里我们可以控制$relation为任意值了,那么有没有那个对象的visable方法或者哪个对象的call魔术方法存在rce的可能呢?前者没有希望,那我们考虑下后者。通过全局搜索call魔术方法,我们发现在Requests.php的Requests类中存在一个call魔术方法,他是这样写的:
这里的$method我们不可控,值为visable,$this->hook是可控的,于是我们就产生了个想法,如果在这个地方控制$this->hook的值为
[‘visable’=>’system’]那么这里的函数就变成了call_user_func_array(‘system’,$args),这里的$args就是Conversion中的$name,而它的值为[‘bcc’,’bcc’],是我们可控的。可是在调用call_user_func_array之前我还调用了array_unshift($this,$this)他将$this放到了$args的最前面,所以$args就变成了[‘$this’,’bcc’,’bcc’]无法实现RCE。
6.利用call里面的call_user_func_array作为跳板,寻找新的链
虽然在call当中不能直接执行rce,但是$this->hook[$method]是我们完全可控的,我们可以控制他为一个新的方法,绕过$args,寻找新的RCE出发点。在Request类中,搜索危险函数,一个函数引起来的我的注意,filterValue()他的内容如下:
1 | private function filterValue(&$value, $key, $filters) |
我们可以看到这里存在一行代码,$value = call_user_func($filter,$value)乍一看过去我们没有可以控制的参数,但是我们可以通过寻找调用filterValue()的其他方法来间接调用filterValue(),如果可以通过他们控制参数,那便可以实现RCE。
7.filterValue()的调用链
通过全局搜索filterValue,我发现Rquest中的input()方法调用过他,源码:
1 | public function input($data = [], $name = '', $default = null, $filter = '') |
我们可以发现这里的$data,$name,$filter也没有控制的机会,继续往上寻找调用input()的类,全局搜索input,Request类中的
param方法调用了input(),代码如下:
1 | public function param($name = '', $default = null, $filter = '') |
param里面也没有发现可控参数,全局搜索param,发现Requests类中的isAjax函数调用了他,于是我们来到isAjax一探究竟,源码如下:
我们可以发现调用param方法的时候传入的参数是$this->config[‘var_ajax’]这是我们可以控制的参数!他对应在param中的值就是
$name,让我们回到param方法最底部的**return $this->input($this->param,$name,$default,$filter)**,这里的$this->param是通过get传过去的所有参数,以参数名为键名参数值为键值所组成的一个数组。$name为我们的可控变量,$default为NULL,$filter为’’
带着这些数据我们进入了input方法,在input方法中存在这样一段代码:
1 | if ('' != $name) { |
此时的$data的值为getData的返回值,我们带着$data,$name进入getData方法
1 | protected function getData(array $data, $name) |
可以看到,首先$name是我们可控的所以$val也是我们可控的,这里的$data形式为
[‘mengd@’=>get传参的数据,’payload’=>get传参的payload]
而又因为会取出$val对应键值的数据存入$data,$val我们又可控。所以这个地方$data就变成了我们可控的数据,在传参时参数名为Mengd@的数据就会保存金$data,对应我们的payload,此时$data的值为calc。
然后进入到input方法中的**$filter = $this->getFilter($filter, $default)**语句,代码为:
1 |
|
可以看到这里的$filter是我们可控的数据,所以$filter为payload中的system,然后$filter[]被赋值$default,$default为null,所以$filter为[‘system’,null]
至此,带着数据$data为calc,$name为Mengd@,$filter为[‘system’,null]进入最终的filterValue()函数,执行call_user_func($filter,$data),最终触发命令执行,至此,整个利用链分析完毕。