ThinkPHP-5.0.x POP链
ThinkPHP 5.0.X代码审计:
前言:
本次记录主要是对ThinkPHP 框架的 5.0.x版本进行代码审计,主要涉及的软件有:
PHPSTORM
Seay源代码审计系统
Phpstudy_pro
PHP版本使用7.3.4
关于PHPSTORM的Xdebug的搭建,我主要参考了暗月的教程
(说实话phpstudy_pro的配置文件真的太麻烦了)
Seay自动审计:
首先还是常规操作,使用Seay源代码审计系统来进行自动审计:
这边出了一堆。不过不是每个都有用的。
主要还是要审计POP链,然后RCE。
目录结构:
首先是对ThinkPHP 5.0目录结构进行查看:
www WEB部署目录(或者子目录) |
这部分可以比较明确的看见每个部分代码的作用是什么,方便到时候思考,或者是跟链子。
构建利用点:
关于控制器文件(Controller):
ThinkPHP的控制器是一个类,接收用户的输入并调用模型和视图去完成用户的需求,控制器层由核心控制器和业务控制器组成,核心控制器由系统内部的App类完成,负责应用(包括模块、控制器和操作)的调度控制,包括HTTP请求拦截和转发、加载配置等。业务控制器则由用户定义的控制器类完成。多层业务控制器的实现原理和模型的分层类似,例如业务控制器和事件控制器。
控制器写法:
控制器文件通常放在application/module/controller
下面,类名和文件名保持大小写一致,并采用驼峰命名(首字母大写)。
一个典型的控制器类定义如下:
|
控制器类文件的实际位置是
application\index\controller\Index.php |
一个例子:
|
想进入后门,需要访问:
http://ip/index.php/Index/backdoor/?command=ls |
像上面这样就可以实现命令执行。
这个框架是需要二次开发,并且实现反序列化才能够进行利用,所以需要手写一个利用点。就写在controller里。
|
利用链分析:
对于PHP反序列化来说,一般来说,比较常见的起点是:
_wakeup() 反序列化后,自动被调用
_destruct() 对象被销毁前,被调用
_toString() 对象被当作字符串输出前,被调用
比较常见的中间跳板是:
__toString 当一个对象被当做字符串使用,自动被调用
__get 读取不可访问或不存在属性时被调用
__set 当给不可访问或不存在属性赋值时被调用
__isset 对不可访问或不存在的属性调用isset()或empty()时被调用
形如 $this->$func();
根据以上两个经验,首先在Seay中进行全局查找。
那么可能存在的POP链大概率就在这部分。
尝试审计:
尝试审计第一个__wakeup()
实际上来说__wakeup()
因为是在进行了反序列化之后才进行的,所以大部分时候是对反序列化内容的限制,很少作为入口,大部分时候可以直接看__destruct()
但是这里还是看一下
从Seay里可以看见,这部分的反序列化函数在:
首先看一下unserialize()
中的值是否可控。
向上看一下$value
这里可以看见value的值被设置为了null。
后面陆续向下看,可以发现的是$value值在这部分被用来存储时间戳
然后在接下来的writeTransform()
函数部分进行使用者需要的数据类型的更改。
然后在readTransform()
部分进行数据类型的变回去(进行了json格式加码,就进行解码,进行了序列化的就反序列化)
因此很容易发现$value
的值是我们不能操控的,所以这里无法利用。
POP链:
有了以上的经验,接下来我们对__destruct()
函数进行审计。
路径:
thinkphp/library/think/process/pipes/Windows.php |
这里首先看一下__destruct()
可以看见这边调用了两个函数,跟进一下。
首先分析一下close()
成员方法。
可以看到这里首先是调用了父类中的close()
方法,这里跟进一下,可以找到父类Pipes
中的close()
方法
这里的作用就是将pipes
数组中存在的文件一一关闭,最后再将pipes
数组清空。
子类中的方法同理,可知close()
用于关闭文件,虽然可以控制传参,但是不能进一步利用。
分析removeFiles()
成员方法。
可以看见这里有一个敏感函数,file_exists()
。当执行该函数的时候,会将参数作为字符串来判断,如果输入的是参数是一个对象,可以触发__toString()
魔术方法
看一下$filename
能不能控制。
这里看一下$this->files
的用法,写入值在__construct()
,不影响,因为反序列化不会调用__construct()
函数
可以在__construct()
看见files数组中,进行定义的过程。
这里使用到了tempnam()
函数,可以再指定的目录中创建一个具有唯一文件名的临时文件。成功返回新的文件名,失败返回false。
另一个函数返回当前操作系统的临时文件目录。
这部分可以看见数组$file
的定义,发现是可以控制的。
跟进到__toString()
,在Seay代码审计系统中进行全局搜索:
这里经过尝试之后,可以直接跟进到Model.php
中的__toString()
参数。**(注意Model是一个抽象类,要进行了继承了之后才能实例化成对象,所以要找一个子类,这里可以选择Pivot)**
跟进到toJson()
方法。
这里使用了json_encode()
函数,函数返回一个字符串,包含了value值json格式的表示。编码会受到options参数的印象。
跟进到toArray()
方法。(太长了,不放截图)
/** |
这里比较长,但是不需要进行特别详细的审计,主要是看看有没有可以利用的危险函数,或者是可以当成跳板的利用点。
简单看了一下,这里没有什么危险函数,所以要考虑找跳板。
这里比较常见的跳板主要是__call()
看看有没有可控的,调用了函数的变量。
可以看到,一共有这三个变量调用了方法,找一下有没有可控的。
利用PHPSTORM的查找写入值,可以比较方便的看见写入和读取的过程。
前提:
首先看$relation
前两个是用getAttr()函数来返回以$key为键名的数组$data的元素值。
后一个是调用了Loader类中的方法,看一下方法:
函数备注了字符串命名风格转换,理论上来说对于输入的字符串$name
是不会有什么影响的,如果$name
可以进行控制的话,那么就可以控制到$relation
。
回头查看一下:
通过查看append
的调用,可以发现append
是可以控制的,那么$name
和$relation
就是可以控制的了。可以通过这里触发__call()
魔术方法。
然后是看$modelRelation
这里有一个写入值的地方。
说实话,这部分我没看懂代码
查了一下之后, 对于这部分代码可以理解为:
$modelRelation = $this->$relation(); //relation是一个可以改变的函数名,可以根据$relatioin不同值,来使得$modelRelation等于不同函数的返回值。 |
同时要进入这部分,需要首先满足method_exists()
这个方法。
用于这部分,就是需要满足$relation()
所指向的方法,是存在于Model类中的方法。
这里选择的是getError()这个方法,因为返回值是可以控制的。
所以只要通过设置$error
为一个对象,同时将$relation
设置为getError,就可以实现对$modelRelation
的控制,进而触发__call()
最后看一下$value
这里可以看见两个写入值的地方,跟进一下getRelationData($modelRelation)
这里首先判断了一下传入的参数是Relation类的对象(也就是$modelRelation)
可以看见下面有一个$value = $this->parent,而$parent
是可控的,这里如果能控制就很方便了。
看看判断条件:
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) |
分析一下:
这里需要$this->parent
存在,$modelRelation
中存在isSelfRelation()
且返回值为0,$modelRelation
中存在getModel()
方法。
满足以上条件之后,就可以进入if,然后令$value=$this->partent
。所以$value
也是可以控制的
触发__call():
接下来就是要考虑怎么调用函数,来触发__call()
。
if (!empty($this->append)) { |
1、if (!empty($this->append))
可以直接控制,进入
2、foreach ($this->append as $key => $name)
控制了$append
,可以直接进入。
3、if (is_array($name))
令上一步中的$name
不是数组,进入。
4、elseif (strpos($name, '.'))
$name
不存在.
,进入。
5、if (method_exists($this, $relation))
要保证在Model类中,$relation
表示的函数存在即可进入。
6、if (method_exists($modelRelation, 'getBindAttr'))
保证在$modelRelation
表示的类中存在getBindAttr()
方法可以进入。
7、if ($bindAttr)
保证$modelRelation->getBindAttr();
存在,可以进入
8、if (isset($this->data[$key])) {
使得$data
中以$key
为键的元素是空即可绕过。
分析:
对于以上的八个关键点,进行分析:
因为我们可以控制$append
,所以我们可以对$key
和$name
的值进行控制(通过第二点的foreach)。
接下来第三点,我们需要保证在$append
中元素不为数组,这很好实现,随便写入一个字符串,例如Ho1L0w-By
(只是一个例子)即可(但实际上后面的要求不一样,只是就目前情况分析)。
第四点,要求$name
,也就是$append
中的元素中不能有.
,写的字符串已经实现了。
第五点和第六点需要一起看,就像是我们之前分析$relation
和$modelRelation
一样,为了控制第六点中的$modelRelation
中存在getBindAttr()
方法,我们需要将$relation
控制写为getError
,这样才能控制$modelRelation
的值,使得$modelRelation
中存在getBindAttr()
那么总结一下上面的六点:
$append
中的$key
和$name
可以控制,且$name
的值必须为getError
,然后通过设置$error
值,来进一步控制$modelRelation
。
而根据我们之前对于getRelationData()
方法中,$value = $this->partent
的分析,这里来总结一下对于$modelRelation
需要的条件
1、是Relation对象
2、存在isSelfRelation()方法,且返回值存在
3、存在getModel()方法,且返回值与get_class($this->parent)相同。(双等号)
4、存在getBindAttr()
进行用法查找:
可以看见这些里面都存在Relation的类。
而看过Relation
类之后可以发现,在所有的Relation的子类中都存在isSelfRelation()
和getModel()
。
这里跟进一下getModel()
函数:
查找一下用法,可以知道$query
是可控的,这里需要知道哪个类的getModel()
方法是可控的,来控制返回值。
可以看见是可控的,选择Query.php。
接下来就是在这些子类中找存在getBindAttr()
方法的类
在这里可以看见,和上面的重合点有一个,就是OneToOne.php里面。
而这里因为OneToOne这个类是抽象类,所以还需要找到它的子类。
这里可以选择HasOne.php。
这里就已经解决了$modelRelation
的需求,可以继续看剩下的7,8点。
第七点需要我们返回的$bindAttr
的值存在,看一下OneToOne.php中的getBindAttr()
方法,可以看见是可控的,简单绕过。
第八点我们对$key的值溯源一下,
看一下这个三元运算,只要$key
是数字,就可以设置$key
的值为$attr
,可以看见$key
和$attr
都是我们可以进行控制的,因为$bindAttr
可以控制。
到这里,已经可以执行我们需要的函数来触发__call()
了。
选择__call():
进行全局搜索,找到一个合适的__call()方法
这里根据前人经验,可以选择Output.php(篇幅有限)
这里是路径:
thinkphp/library/think/console/Output.php |
在这里主要需要看的是这两个函数:
array_unshift()
,call_user_func_array()
。
array_unshift()
函数用于向数组插入新元素。新数组的值将被插入到数组的开头。
call_user_func_array
— 调用回调函数,并把一个数组参数作为回调函数的参数
可以看到第一个没什么用,但是第二个比较有意思,这里可以调用回调函数。
什么是回调函数?
通俗的来说,回调函数是一个我们定义的函数,但是不是我们直接来调用,而是通过另一个函数来调用,这个函数通过接收回调函数的名字和参数来实现对它的调用。
看看手册里的说明。
因为是在
$item[$key] = $value ? $value->getAttr($attr) : null; |
对__call()进行的触发,所以此处在__call()中的参数,$method
是getAttr()
,$args
是$attr
的值。
第一个if中,可以看见styles是可控的。
将$styles
中的值多添加一个getAttr()
即可进入
这里跟进类中的block
方法:
跟进writeln
(一看就很敏感)
跟进write
查看一下$handle
的用法
反序列化是不会调用__construct()
的,因此$handle
可控
因此可以全局查看一下哪里的write可以利用:
这里可以看见有好几个write函数存在,也有多个可以利用的点。这里主要让我们看一下Memcache.php
中的Write函数。
thinkphp/library/think/session/driver/Memcache.php |
$handler
可控,因此可以随便调用任何文件中的set函数,全局查找set函数:
这里还是使用Seay进行查找。
这里可以看见很多不同的函数使用文件,可以都看一下,这里如果是想要使用写入webshell,主要的利用点在File.php
文件中,文件路径:
thinkphp/library/think/cache/driver/File.php |
可以看见危险函数file_put_contents($filename,$data)
,这里可以用来写入webshell。具体内容可以由我们自己决定。
这里一般来说,只要我们使用一个<?php phpinfo(); ?>
,然后访问对应文件,出现了详情页面,就可以用来证明漏洞存在了。
这里分析一下如何利用到这个file_put_contents()
函数。
第一个if是判断$expire
的,对$expire
进行了设置。
第二个if用来判断$expire
是不是DataTime
的子类,设置时间戳。
然后将$filename
调用getCacheKey()
函数进行了值的设置,因为$filename
是file_put_contents()
函数中的一个参数,所以这里我们跟进函数。
protected function getCacheKey($name, $auto = false) |
可以看见两个if主要是用来更改文件名的,因为$options
可以控制,所以可以直接修改之后绕过。
然后到了$filename
进行设置的地方了,这里同样因为$options
可以进行控制,所以基本是可以确定文件名是可控的,同时文件的后缀也是被写死了是.php。
后面的函数不会影响$filename
,因此可以确定$filename
可以控制。
继续分析,可以看见$data作为file_put_contents()
函数的参数是进行序列化出来的,参数是使用的$value
。
这里会出现两个问题,因为$value
这个值是调用函数时传入的参数,在writeln
中一路传过来的时候,已经是被确定了为布尔值的true
,因此我们不能对$value
达成控制的效果。
而这里,也可以看见$data
的值也是被写死了,并且存在一个exit()
函数,需要进行死亡绕过。
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data; //这里连接了一个$data |
如果不能解决这两个问题,这条链子是没法调用的。
这里需要往下看
跟进到setTagItem(),
可以看见这里将$filename
作为参数传递进去,同时在下方继续对set()函数进行了调用,将$key和$value作为参数传递了回去。
可以看见,在这里的$value
是赋值为了$filename
的值,因此,如果是构造了较为合理的$filename
,那么就可以进行文件的写入。
写入了文件之后,需要考虑到代码执行的问题,因此需要对exit()函数进行绕过,这里需要用到PHP伪协议的知识,来对exit()函数进行死亡绕过。
到这里,这条链子算是走通了。
EXP:
按照我们现在进行的一系列分析,可以尝试写出EXP如下:
|
运行后生成:
TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mzp7czo5OiIAKgBhcHBlbmQiO2E6MTp7aToxO3M6ODoiZ2V0RXJyb3IiO31zOjg6IgAqAGVycm9yIjtPOjI3OiJ0aGlua1xtb2RlbFxyZWxhdGlvblxIYXNPbmUiOjM6e3M6MTU6IgAqAHNlbGZSZWxhdGlvbiI7aTowO3M6MTE6IgAqAGJpbmRBdHRyIjthOjE6e2k6MDtzOjM6Inh4eCI7fXM6ODoiACoAcXVlcnkiO086MTQ6InRoaW5rXGRiXFF1ZXJ5IjoxOntzOjg6IgAqAG1vZGVsIjtPOjIwOiJ0aGlua1xjb25zb2xlXE91dHB1dCI6Mjp7czo5OiIAKgBzdHlsZXMiO2E6MTp7aTowO3M6NzoiZ2V0QXR0ciI7fXM6Mjg6IgB0aGlua1xjb25zb2xlXE91dHB1dABoYW5kbGUiO086MzA6InRoaW5rXHNlc3Npb25cZHJpdmVyXE1lbWNhY2hlZCI6MTp7czoxMDoiACoAaGFuZGxlciI7TzoyMzoidGhpbmtcY2FjaGVcZHJpdmVyXEZpbGUiOjI6e3M6MTA6IgAqAG9wdGlvbnMiO2E6NTp7czo2OiJleHBpcmUiO2k6MDtzOjEyOiJjYWNoZV9zdWJkaXIiO3M6MToiMCI7czo2OiJwcmVmaXgiO3M6MToiMCI7czo0OiJwYXRoIjtzOjEwNjoicGhwOi8vZmlsdGVyL2NvbnZlcnQuaWNvbnYudXRmLTgudXRmLTd8Y29udmVydC5iYXNlNjQtZGVjb2RlL3Jlc291cmNlPXh4eFBEOXdhSEFnY0dod2FXNW1ieWdwT3o4Ky8uLi9hLnBocCI7czoxMzoiZGF0YV9jb21wcmVzcyI7YjowO31zOjY6IgAqAHRhZyI7czoxOiIxIjt9fX19fXM6OToiACoAcGFyZW50IjtyOjExO319fQ |
传入:
效果图:
这里分析一下文件名是怎么生成的
第一次进入set函数的时候:
首先将$name进行md5加密,然后连接到$this->options[‘path’]后面,再加上.php
可以得到$filename
如下:
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=xxxPD9waHAgcGhwaW5mbygpOz8+/../a.php8db7a8c80e67e908f96fbf22dde11df3.php |
然后进行file_put_contents()
,可以得到第一个文件,同时第一个$data值是将恒为true的$value反序列化,得到b:1;
第二次进入set函数的时候:
会经过setTagtem()函数,进行重新赋值,进入到has方法,跟进到get方法,然后重新调用到File类的getCacheKey方法,此时的$name是tag_md5(“1”),也就是tag_c4ca4238a0b923820dcc509a6f75849b
然后上面的再次md5,得到3b58a9545013e88c7186db11bb158c44
,按照之前的方法,连接到后面,就会出现新的$filename
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=xxxPD9waHAgcGhwaW5mbygpOz8+/../a.php3b58a9545013e88c7186db11bb158c44.php |
因为这个文件不存在,会返回false所以会跳过if($this->has($key)),直接令$value等于输入的$name,也就是tag_md5(“1”),也就是tag_c4ca4238a0b923820dcc509a6f75849b
然后再次进入set()函数,这一次会进入getCacheKey()函数,然后再次md5加密,得到md5(tag_md5(“1”)),也就是$filename
php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=xxxPD9waHAgcGhwaW5mbygpOz8+/../a.php3b58a9545013e88c7186db11bb158c44.php |
然后因为第一次进入setTagItem()函数的时候,会将tag设置为null,所以不会再进入,写入成功。
因此最后我们需要的文件名应该是这个格式:
<?php |
两次md5都是getCacheKey中的函数。
参考:
https://xz.aliyun.com/t/7457#toc-3
https://www.moonsec.com/4586.html
https://www.anquanke.com/post/id/196364#h2-5
https://www.anquanke.com/post/id/265088#h2-4