开始之前

这是大约一个月前joomla的漏洞,现在写有点晚了,不得不说假期确实是消磨人性呀。由于对joomla的架构不熟悉,审计过程中走了不少弯路,文章可能写的比较啰嗦,利用方式直接掺在分析过程中一起写了。求大牛轻喷吧,233333。

漏洞点

payload出库

这次出现漏洞的地方代码比较简单,在administrator\templates\hathor\postinstall\hathormessage.php中,这是joomla自带的一套模板。

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
34
/**
* Checks if hathor is the default backend template or currently used as default style.
* If yes we want to show a message and action button.
*
* @return bool
*
* @since 3.7
*/
function hathormessage_postinstall_condition()
{
$db = JFactory::getDbo();
$user = JFactory::getUser();
$globalTemplate = 'n/a';
$template = 'n/a';

// We can only do that if you have edit permissions in com_templates
/*省略*/

// Get the current user admin style
$adminstyle = $user->getParam('admin_style', '');

if ($adminstyle != '')
{
$query = $db->getQuery(true)
->select('template')
->from($db->quoteName('#__template_styles'))
->where($db->quoteName('id') . ' = ' . $adminstyle[0])
->where($db->quoteName('client_id') . ' = 1');

// Get the template name associated to the admin style
$template = $db->setquery($query)->loadResult();
}
/*省略*/
}

函数的功能注释已经写的很详细了,检查当前后台的模板是否为hathor。那么我们就可以大胆猜测,每次进入后台的时候这个函数都会被调用。事实证明确实是这样,具体的代码暂时不说。

漏洞出现的原因就是在获取到$admin_style之后,没有对其进行类型强转或者过滤等操作,而是用了$admin_style[0]这种奇怪的操作,导致了漏洞的出现,这是这个二次注入的第二步

我们先看一下$admin_style是怎么来的。

一路跟踪getParam()来到了libraries/vendor/joomla/registry/src/Registry.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function get($path, $default = null)
{
// Return default value if path is empty
if (empty($path))
{
return $default;
}

if (!strpos($path, $this->separator))
{
return (isset($this->data->$path) && $this->data->$path !== null && $this->data->$path !== '') ? $this->data->$path : $default;
}
/*省略*/
}

查看参数是否存在分隔符(默认为.),不存在就返回data对象的属性或者默认值(空)。再来看一下data对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function __construct($data = null)
{
// Instantiate the internal data object.
$this->data = new \stdClass;

// Optionally load supplied data.
if (is_array($data) || is_object($data))
{
$this->bindData($this->data, $data);
}
elseif (!empty($data) && is_string($data))
{
$this->loadString($data);
}
}

这里把data看做是一个存储数据的全局变量即可,再粗暴一点理解成一个全局数组也行,用法是一样的。

payload入库

既然是$admin_style出了问题,那我们就全局搜一下admin_style

1
2
3
4
5
6
7
F:\PHP\Joomla_3.8.3-Stable-Full_Package\administrator\components\com_admin\models\forms\profile.xml:
102
103 <field
104: name="admin_style"
105 type="templatestyle"
106 label="COM_ADMIN_USER_FIELD_BACKEND_TEMPLATE_LABEL"
......

对joomla的架构不熟,这个地方花了不少时间。这个xml的作用就是post请求时要传递的参数以及他们的默认值,上面说到的获取默认值其实就是获取这里的值。根据xml所在位置可以判定关键点就在后台管理员修改个人信息的地方(其实还有一处,即超级管理员修改用户信息的地方,但是所需权限太高,前者只需要进入后台的权限即可)。

访问/administrator/index.php?option=com_admin&view=profile&layout=edit&id=925修改个人信息后抓包,我们看到了admin_style参数。

mark

但是如果直接这样构造payload是不管用的,还记得前面的神操作$admin_style[0]吗,他会将payload作为字符串来处理,只取第一个字符,这么看来似乎还有点防御能力?如何让$admin_style[0]取到全部的payload,php里简单的就像喝水一样。

mark

此时payload已经存入数据库了

mark

以上便是这个二次注入的第一步

关于joomla对于sql语句的处理,涉及到框架低层的东西,也不是这次的重点,所以没有深入。

漏洞触发点

一通操作之后回到主页,发现payload已经生效了:

mark

到这里差不多就该结束了,但是我感觉这属于比较特殊的情况,正好在主页就有触发点,一般的二次注入都比较隐蔽,所以我决定再深入一下下,当然也不出意外的踩了许多坑。。。

访问administrator/index.php默认路由到com_cpanel这个包

administrator/includes/helper.php

1
2
3
4
5
6
7
8
9
10
11
public static function findOption()
{
/*省略*/
if (empty($option))
{
$option = 'com_cpanel';
}

$app->input->set('option', $option);
return $option;
}

再到administrator/components/com_cpanel/views/cpanel/view.html.php

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
public function display($tpl = null)
{
// Set toolbar items for the page
JToolbarHelper::title(JText::_('COM_CPANEL'), 'home-2 cpanel');
JToolbarHelper::help('screen.cpanel');

$input = JFactory::getApplication()->input;

/*
* Set the template - this will display cpanel.php
* from the selected admin template.
*/
$input->set('tmpl', 'cpanel');

// Display the cpanel modules
$this->modules = JModuleHelper::getModules('cpanel');

try
{
$messages_model = FOFModel::getTmpInstance('Messages', 'PostinstallModel')->eid(700);
$messages = $messages_model->getItemList();
}
catch (RuntimeException $e)
{
$messages = array();
// Still render the error message from the Exception object
JFactory::getApplication()->enqueueMessage($e->getMessage(), 'error'); //这个函数将MySQL的报错信息显示到主页上
}
$this->postinstall_message_count = count($messages);
parent::display($tpl);
}

到这里目标就很明显了,继续跟getItemList()

libraries/fof/model/model.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function &getItemList($overrideLimits = false, $group = '')
{
if (empty($this->list))
{
$query = $this->buildQuery($overrideLimits);

if (!$overrideLimits)
{
$limitstart = $this->getState('limitstart');
$limit = $this->getState('limit');
$this->list = $this->_getList((string) $query, $limitstart, $limit, $group);
}
else
{
$this->list = $this->_getList((string) $query, 0, 0, $group);
}
}

return $this->list;
}

这里构造的sql语句为

1
SELECT `#__postinstall_messages`.* FROM `#__postinstall_messages` WHERE `extension_id` = '700' AND `enabled` = '1' ORDER BY `postinstall_message_id` ASC

执行结果

mark

到这里整个流程就比较清晰了,后面就比较简单了。

1
2
3
4
5
6
7
8
9
protected function &_getList($query, $limitstart = 0, $limit = 0, $group = '')
{
$this->_db->setQuery($query, $limitstart, $limit);
$result = $this->_db->loadObjectList($group);

$this->onProcessList($result);

return $result;
}

获取到postinstall_messsage,下面是$result的部分输出结果

mark

继续

administrator/components/com_postinstall/models/messages.php

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
34
35
protected function onProcessList(&$resultArray)
{
$unset_keys = array();
$language_extensions = array();

// Order the results DESC so the newest is on the top.
$resultArray = array_reverse($resultArray);

foreach ($resultArray as $key => $item) //遍历结果集
{
// Filter out messages based on dynamically loaded programmatic conditions.
if (!empty($item->condition_file) && !empty($item->condition_method))
{
jimport('joomla.filesystem.file');

$file = FOFTemplateUtils::parsePath($item->condition_file, true);

if (JFile::exists($file))
{
require_once $file; //包含指定文件

$result = call_user_func($item->condition_method); //执行有漏洞的函数

if ($result === false)
{
$unset_keys[] = $key;
}
}
}

// Load the necessary language files.
/*略*/
}
/*略*/
}

遍历结果集,包含指定文件,执行函数,打完收工~

防御

当然是升级啦。

joomla 3.8.4 做了如下处理

1
2
3
4
5
6
7
8
9
10
11
$adminstyle = $user->getParam('admin_style'); //没有设置默认值
if ($adminstyle)
{
$query = $db->getQuery(true)
->select('template')
->from($db->quoteName('#__template_styles'))
->where($db->quoteName('id') . ' = ' . (int) $adminstyle) //强制类型转换
->where($db->quoteName('client_id') . ' = 1');
// Get the template name associated to the admin style
$template = $db->setquery($query)->loadResult();
}

参考文章

分析CVE-2018-6376 – Joomla!二阶SQL注入 文章还介绍了利用sqlmap进行自动化攻击

从补丁到漏洞分析 –记一次joomla漏洞应急