Author: p0wd3r (知道创宇404安全实验室)
Date: 2016-12-21
Joomla 于12月13日发布了3.6.5的升级公告,此次升级修复了三个安全漏洞,其中 CVE-2016-9838 被官方定为高危。根据官方的描述,这是一个权限提升漏洞,利用该漏洞攻击者可以更改已存在用户的用户信息,包括用户名、密码、邮箱和权限组 。经过分析测试,成功实现了水平用户权限突破,但没有实现垂直权限提升为管理员。
触发漏洞前提条件:
成功攻击后攻击者可以更改已存在用户的用户信息,包括用户名、密码、邮箱和权限组 。
1.6.0 - 3.6.4
docker-compose.yml:
version: '2'
services:
db:
image: mysql
environment:
- MYSQL_ROOT_PASSWORD=hellojm
- MYSQL_DATABASE=jm
app:
image: joomla:3.6.3
depends_on:
- db
links:
- db
ports:
- "127.0.0.1:8080:80"
然后在 docker-compose.yml 所在目录执行docker-compose up
,访问后台开启注册再配置SMTP即可。
官方没有给出具体的分析,只给了描述:
翻译过来就是:
对表单验证失败时存储到 session 中的未过滤数据的不正确使用会导致对现有用户帐户的修改,包括重置其用户名,密码和用户组分配。
因为没有具体细节,所以我们先从补丁下手,其中这个文件的更改引起了我的注意:
https://github.com/joomla/joomla-cms/commit/435a2226118a4e83ecaf33431ec05f39c640c744
可以看到这里的$temp
是 session 数据,而该文件又与用户相关,所以很有可能就是漏洞点。
我们下面通过这样两个步骤来分析:
1.寻找输入点
我们找一下这个 session 是从哪里来的:
在components/com_users/controllers/registration.php
中设置,在components/com_users/models/registration.php
中获取。我们看components/com_users/controllers/registration.php
中第108-204行的register
函数:
public function register()
{
...
$data = $model->validate($form, $requestData);
// Check for validation errors.
if ($data === false)
{
...
// Save the data in the session.
$app->setUserState('com_users.registration.data', $requestData);
...
}
// Attempt to save the data.
$return = $model->register($data);
// Check for errors.
if ($return === false)
{
// Save the data in the session.
$app->setUserState('com_users.registration.data', $data);
...
}
...
}
这两处设置 session 均在产生错误后进行,和漏洞描述相符,并且$requestData
是我们原始的请求数据,并没有被过滤,所以基本可以把这里当作我们的输入点。
我们来验证一下,首先随便注册一个用户,然后再注册同样的用户并开启动态调试:
由于这个用户之前注册过,所以验证出错,从而将请求数据写入了 session 中。
取 session 的地方在components/com_users/models/registration.php
的getData
函数,该函数在访问注册页面时就会被调用一次,我们在这时就可以看到 session 的值:
由于存储的是请求数据,所以我们还可以通过构造请求来向 session 中写入一些额外的变量。
2.梳理处理逻辑
输入点找到了,下面来看我们输入的数据在哪里被用到。我们看components/com_users/models/registration.php
的register
函数:
public function register($temp)
{
$params = JComponentHelper::getParams('com_users');
// Initialise the table with JUser.
$user = new JUser;
$data = (array) $this->getData();
// Merge in the registration data.
foreach ($temp as $k => $v)
{
$data[$k] = $v;
}
// Prepare the data for the user object.
$data['email'] = JStringPunycode::emailToPunycode($data['email1']);
$data['password'] = $data['password1'];
$useractivation = $params->get('useractivation');
$sendpassword = $params->get('sendpassword', 1);
...
// Bind the data.
if (!$user->bind($data))
{
$this->setError(JText::sprintf('COM_USERS_REGISTRATION_BIND_FAILED', $user->getError()));
return false;
}
// Load the users plugin group.
JPluginHelper::importPlugin('user');
// Store the data.
if (!$user->save())
{
$this->setError(JText::sprintf('COM_USERS_REGISTRATION_SAVE_FAILED', $user->getError()));
return false;
}
...
}
在这里调用了之前的getData
函数,然后使用请求数据对$data
赋值,再用$data
对用户数据做更改。
首先跟进$user->bind($data)
,在libraries/joomla/user/user.php
中第595-693行:
public function bind(&$array)
{
...
// Bind the array
if (!$this->setProperties($array))
{
$this->setError(JText::_('JLIB_USER_ERROR_BIND_ARRAY'));
return false;
}
// Make sure its an integer
$this->id = (int) $this->id;
return true;
}
这里根据我们传入的数据对对象的属性进行赋值,setProperties
并没有对赋值进行限制。
接下来我们看$user->save($data)
,在libraries/joomla/user/user.php
中第706-818行:
public function save($updateOnly = false)
{
// Create the user table object
$table = $this->getTable();
$this->params = (string) $this->_params;
$table->bind($this->getProperties());
...
if (!$table->check())
{
$this->setError($table->getError());
return false;
}
...
// Store the user data in the database
$result = $table->store();
...
}
具体内容就是将$user
的属性绑定到$table
中,然后对$table
进行检查,这里仅仅是过滤特殊符号和重复的用户名和邮箱,如果检查通过,将数据存入到数据库中,存储数据的函数在libraries/joomla/table/user.php
中:
/**
* Method to store a row in the database from the JTable instance properties.
*
* If a primary key value is set the row with that primary key value will be updated with the instance property values.
* If no primary key value is set a new row will be inserted into the database with the properties from the JTable instance.
*
* @param boolean $updateNulls True to update fields even if they are null.
*
* @return boolean True on success.
*
* @since 11.1
*/
public function store($updateNulls = false)
如果主键存在则更新,主键不存在则插入。
整个的流程看下来我发现这样一个问题:
如果$data
中有id
这个属性并且其值是一个已存在的用户的 id ,由于在bind
和save
中并没有对这个属性进行过滤,那么最终保存的数据就会带有 id 这个主键,从而变成了更新操作,也就是用我们请求的数据更新了一个已存在的用户。
实际操作一下,我们之前注册了一个名字为 victim 的用户,数据库中的 id 是57:
然后我们以相同的用户名再发起一次请求,然后截包,添加一个值为57名为jform[id]
的属性:
放行后由于重复注册从而发生错误,程序随后将请求数据记录到了 session 中:
接下来我们发送一个新的注册请求,用户名邮箱均为之前未注册过的,在save
函数处下断点:
id 被写进了$user
中。然后放行请求,即可在数据库中看到结果:
之前的 victim 已被新用户 attacker 取代。
整个攻击流程总结如下:
(上面的演示中A和C是同一个用户)
需要注意的是我们不能直接发送一个带有 id 的请求来更新用户,这样的请求会在validate
函数中被过滤掉,在components/com_users/controllers/registration.php
的register
函数中:
public function register()
{
...
$data = $model->validate($form, $requestData);
// Check for validation errors.
if ($data === false)
{
...
// Save the data in the session.
$app->setUserState('com_users.registration.data', $requestData);
...
}
// Attempt to save the data.
$return = $model->register($data);
...
}
所以我们采用的是先通过validate
触发错误来将 id 写到 session 中,然后发送正常请求,在register
中读取 session 来引入 id,这样就可以绕过validate
了。
另外一点,实施攻击后被攻击用户的权限会被改为新注册用户的权限(一般是 Registered),这个权限目前我们无法更改,因为在getData
函数中对groups
做了强制赋值:
$temp = (array) $app->getUserState('com_users.registration.data', array());
...
// Get the groups the user should be added to after registration.
$this->data->groups = array();
// Get the default new user group, Registered if not specified.
$system = $params->get('new_usertype', 2);
$this->data->groups[] = $system;
所以目前只是实现了水平权限的提升,至于是否可以垂直权限提升以及怎么提升还要等官方的说明或者是大家的分析。
由于没有技术细节,一切都是根据自己的推断而来,如有错误,还望指正 :)
使用 session 时仅允许使用指定的属性。
升级至3.6.5 https://www.joomla.org/announcements/release-news/5693-joomla-3-6-5-released.html