Joomla二次注入(CVE-2018-6376)分析

开始之前

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

漏洞点

payload出库

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

/**
 * 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

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对象

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

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

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

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

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语句为

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

执行结果

mark

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

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

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 做了如下处理

$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漏洞应急

标签: joomla
添加新评论