
Magento Unauthorized Remote Code Execution (CVE-2016-4010)
0x00 Preface
On May 17, foreign security researcher Netanel Rubin publicly disclosed an unauthenticated remote code execution vulnerability in Magento (CVE-2016-4010). This vulnerability actually consists of multiple smaller flaws and allows an attacker to execute arbitrary PHP code on a vulnerable Magento server without authentication. Magento is a very popular e-commerce platform, acquired by eBay in 2011. Some well-known companies such as Samsung, Nikon, Lenovo, and many small e-commerce sites use it. It is reported that Magento is used by over 250,000 online stores, handling transactions worth approximately $60 billion annually.
0x01 Analysis
Prerequisites for exploiting this vulnerability:
Magento's web API allows two types of RPCs: REST RPC and SOAP API. Both offer the same functionality, with the only difference being that the former uses JSON and HTTP requests to pass input, while the latter uses XML.
To expose only certain module APIs, Magento provides developers with a convenient method: declare only the APIs they want to make accessible in the "webapi.xml" file. The webapi.xml file contains all the classes and methods for the Web APIs that need to be exposed, and each method also specifies the required permission. These permissions include:
Of course, this approach, which allows developers to use the webapi.xml file to communicate between the system's frontend and backend (Web API), essentially opens a backdoor directly into the module core.
Additionally, even with "anonymous" permission, we still need a way to dynamically pass values. For example, the "CustomerRepositoryInterface::save()" API function allows us to use a "CustomerInterface" object in the "$customer" variable. The code prototype is as follows:
interface CustomerRepositoryInterface
{
/**
* Create customer.
*/
public function save(\Magento\Customer\Api\Data\CustomerInterface $customer);
}
So how can we create an object using the RPC interface? In fact, the answer lies in how Magento configures the SOAP server.
Magento uses the PHP "SoapServer" bundled by default. To be properly configured, "SoapServer" requires a WSDL file that defines all methods, parameters, and custom types used in actual RPC requests. Magento generates different WSDL files for each module that supports XMLRPC functionality and directly sets values from the module's webapi.xml file.
When an RPC request is parsed by the server, the server uses the data found in the WSDL file to determine if the request is valid, checking the request's method, parameters, and types. If the request is valid, the parsed request object is passed to Magento for further processing. One very important point: the "SoapServer" does not interact with Magento in any way; all information about the module's methods and parameters comes from the WSDL file. At this point, the sent request still consists of nested arrays, and no objects are created during the SoapServer parsing phase. To create the required objects, Magento handles the input itself.
To extract parameter names and data types, Magento retrieves the prototype from the request's method (see the code above). For basic data types such as strings, arrays, booleans, etc., the system maps the input to the corresponding type. However, for object types, the resolution is more complicated.
If the parameter's data type is a class instance, Magento will attempt to create an instance using the provided input. Remember, the input at this point is just a dictionary, with keys as property names and values as property values.
First, Magento creates a new instance of the required class. Then, it tries to populate it using the following method:
Magento processes each property the user is trying to set in this manner. Once all properties have been checked, Magento considers the instance fully set and moves to the next parameter. After all parameters have been processed, Magento finally executes the API method.
In summary, Magento allows you to create an object, set its public properties, and then execute any method starting with "Set" through its RPC. And it is this behavior that leads to the vulnerability.
Research found that some API calls allow setting specific information in a shopping cart, such as shipping addresses, products, or even payment methods.
When Magento sets our information in the cart instance, it uses the instance's "save" method to store the newly added data in the database.
Let's take a look at how the "save" method works!
/**
* Save object data
*/
public function save(\Magento\Framework\Model\AbstractModel $object)
{
...
// If the object is valid and can be saved
if ($object->isSaveAllowed()) {
// Serialize whatever fields need serializing
$this->_serializeFields($object);
...
// If the object already exists in the DB, update it
if ($this->isObjectNotNew($object)) {
$this->updateObject($object);
// Otherwise, create a new record
} else {
$this->saveNewObject($object);
}
// Unserialize the fields we serialized
$this->unserializeFields($object);
}
...
return $this;
}
// AbstractDb::save()
Magento ensures our object is valid, then serializes all parts that should be serialized and stores them in the database, and finally unserializes the parts that were serialized earlier.
Seems simple, right? Actually, not so. Let's continue to see how Magento determines which parts should be serialized.
/**
* Serialize serializable fields of the object
*/
protected function _serializeFields(\Magento\Framework\Model\AbstractModel $object)
{
// Loops through the '_serializableFields' property
// (containing hardcoded fields that should be serialized)
foreach ($this->_serializableFields as $field => $parameters) {
// Get the field's value
$value = $object->getData($field);
// If it's an array or an object, serialize it
if (is_array($value) || is_object($value)) {
$object->setData($field, serialize($value));
}
}
}
// AbstractDb::_serializeFields()
As we can see, only those fields present in the hardcoded dictionary "_serializableFields" can be serialized. Most importantly, this method continues to serialize only after ensuring the field's value is an array or an object.
Now, let's see how Magento determines which parts should be unserialized.
/**
* Unserialize serializeable object fields
*/
public function unserializeFields(\Magento\Framework\Model\AbstractModel $object)
{
// Loops through the '_serializableFields' property
// (containing hardcoded fields that should be serialized)
foreach ($this->_serializableFields as $field => $parameters) {
// Get the field's value
$value = $object->getData($field);
// If it's not an array or an object, unserialize it
if (!is_array($value) && !is_object($value)) {
$object->setData($field, unserialize($value));
}
}
}
// AbstractDb::unserializeFields ()
Well, it looks very similar. The only difference is that this time Magento needs to ensure the field's value is not an array or an object. Because of these two checks, we should be able to perform an object injection attack by simply setting a crafted string in a serializable field. When we do so, the system will not serialize this field before storing the object in the database because it is not an object or array. However, when the system later tries to unserialize it, after the database query has been executed, it will be unserialized because it is not an object or array.
But it is this almost invisible condition that creates the vulnerability. The remaining question is which fields are considered "serializable" and how we can set them.
Of course, the first question is simple: I just need to search for classes that contain the "_serializableFields" property. Soon, an API method was found in the "Payment" class, but not as a parameter, so it is not possible to create or control its instance properties. Most importantly, its serializable field "additional_information" can only be set as an array, using the "Set[PROPERTY_NAME]" technique as an additional security measure, so not only can we not create it, but even if we could, we couldn't set it as a string.
But interestingly, it can be set in a different, "slick" way. When Magento sets the properties of a parameter instance, it does not actually set the properties; rather, it stores them in a dictionary named "_data". This dictionary is used when an instance's property is accessed. For us, this means our serializable field – "additional_information" – is actually stored in an internal dictionary rather than as a normal property.
So, if we can fully control the "_data" dictionary, we can easily bypass the array restriction on the "additional_information" field, because we can set it manually instead of calling "Set[PROPERTY_NAME]".
But how do we control this sensitive dictionary?
Before saving our "Payment" instance, one of the things Magento does is edit its properties. Magento treats our API input as payment information that needs to be stored in the "Payment" instance, as follows:
/**
* Adds a specified payment method to a specified shopping cart.
*/
public function set($cartId, \Magento\Quote\Api\Data\PaymentInterface $method)
{
$quote = $this->quoteRepository->get($cartId); // Get the cart instance
$payment = $quote->getPayment(); // Get the payment instance
// Get the data from the user input
$data = $method->getData();
// Check for additional data
if (isset($data['additional_data'])) {
$data = array_merge($data, (array)$data['additional_data']);
unset($data['additional_data']);
}
// Import the user input to the Payment instance
$payment->importData($data);
...
}
// PaymentMethodManagement::set()
As we can see, the "Payment" data is obtained by calling "$method->getData()" from the "$method" parameter, which returns the "_data" attribute. Remember, since "$method" is a parameter of the API method, we can control it.
When Magento calls "getData()" on our "$method" parameter, the parameter's "_data" attribute is returned, containing all the payment information we injected. Then, it calls "importData()" with the "_data" attribute as input, replacing the "Payment" instance's "_data" attribute with our "_data" attribute. At this point, we can now replace the sensitive "_data" attribute of the "Payment" instance with our controllable "_data" attribute, meaning we can now set the "additional_information" field.
For unserialize() to work, we need the field to be settable as a string, but the "Set[PROPERTY_NAME]" method only allows arrays. The solution lies in the two lines of code placed before calling "importData()". Magento allows developers to add their own payment methods, providing their own data and information. To achieve this, Magento uses the "additional_data" field. This field is a dictionary containing additional data for the payment method and is fully user-controllable. To make the customized content part of the original data, Magento merges the "additional_data" dictionary with the original "data" dictionary. This effectively allows the "additional_data" dictionary to override any values in the "data" dictionary, essentially allowing complete overwriting. This means that after the two dictionaries are merged, the user-controllable "additional_data" dictionary now becomes the parameter's "_data" dictionary, and because of "importData()", it also becomes the sensitive "_data" attribute of the "Payment" instance. In other words, we now have full control over the serializable field "additional_information" and can perform object injection.
Since we can unserialize any string we want, it's time to perform an object injection attack.
First, we need an object with a "__wakeup()" or "__destruct()" method that will be automatically called when the object is unserialized or destroyed. This is because even though we can control the object's properties, we cannot call its methods. That's why we must rely on PHP's magical methods, which are automatically called when a certain event occurs.
The first object we will use is an instance of the "Credis_Client" class, which contains the following methods:
/*
* Called automaticlly when the object is destrotyed.
*/
public function __destruct()
{
if ($this->closeOnDestruct) {
$this->close();
}
}
/*
* Closes the redis stream.
*/
public function close()
{
if ($this->connected && ! $this->persistent) {
...
$result = $this->redis->close();
}
...
}
// Credis_Client::__destruct(), close()
We can see that this class has a simple "__destruct" method (automatically called by PHP when the object is destroyed) that calls the "close()" method. Interestingly, the "close()" method, if it finds an active connection to a Redis server, will call "close()" on the "redis" property to close it.
Since unserialize() allows us to control all object properties, we can also control the "redis" property. We can set any object we want in this property (not just Redis) and call any "close()" method on any class in the system. This greatly expands our attack surface. There are several "close()" methods in Magento, and since they are typically used to close streams, file handles, and store object data, we should be able to find some interesting calls.
As expected, we found the following "close()" method in the "Transaction" class:
/**
* Close this transaction
*/
public function close($shouldSave = true)
{
...
if ($shouldSave) {
$this->save();
}
...
}
/**
* Save object data
*/
public function save()
{
$this->_getResource()->save($this);
return $this;
}
// Magento\Sales\Model\Order\Payment\Transaction::__destruct(), close()
It looks simple: the "close()" method calls "save()", which in turn calls the "save()" method on the "_resource" property. Following the same logic, since we control the "_resource" property, we can also control its class, so we can call the "save()" method on any class we want.
A big step forward. As we suspected, the "save()" method is typically used to save various data to storage media (e.g., filesystem, database, etc.). Now we just need to find a "save()" method that uses the filesystem as its storage medium.
Soon, I found one:
/**
* Try to save configuration cache to file
*/
public function save()
{
...
// save stats
file_put_contents($this->getStatFileName(), $this->getComponents());
...
}
// Magento\Framework\Simplexml\Config\Cache\File::save()
This method actually saves the data from the "components" field into a file. Since the file path is obtained from the "stat_file_name" field, and we control both parameters, we effectively control the file path and content. This creates an arbitrary file write vulnerability.
Now we just need to find a valid writable path accessible by the web server. In all Magento installations, there is a "/pub" directory used to store images or files uploaded by administrators. This is a usable path.
Finally, we simply write a PHP webshell file to the server, allowing us to execute arbitrary PHP code on the Magento server without authentication.
0x02 Exploitation
Test Environment Setup
Note: Some issues may be encountered; refer to:
Exploitation
The exploit published on exploit-db can be downloaded at: https://www.exploit-db.com/exploits/39838/
Exploitation steps:
Find a vulnerable Magento website Check Magento version online: http://magentoversion.com/
Add a product to the shopping cart

Enter the shopping cart and click "Checkout"

Fill in the shipping address and view the POST request to /rest/default/V1/guest-carts/[guestCartId]/shipping-information and obtain [guestCartID]

Save the exploit above as magento_exp.php and execute: php magento_exp.php [Magento_URL] [guestCartID] ([webshell_write_path])

Batch Scanning
After researching the exploit above, it was found that exploitation requires the following conditions:

Therefore, a simple batch verification script was written to assist with the exploit above:
#!/usr/bin/env python
import urllib
import sys
import socket
timeout = 5
socket.setdefaulttimeout(timeout)
input = sys.argv[1] # File containing URLs of Magento sites
output = sys.argv[2] # Output file for results, e.g., output.txt
def logFile(str):
f = open(output,'a')
f.write(str+"\n")
f.close()
def checkVul(url):
try:
html = urllib.urlopen(url).read()
if "guest-carts" in html:
print url,"is vulnerable!"
logFile(url)
else:
print url,"is not vulnerable!"
except Exception:
pass
if __name__ == '__main__':
inp = open(input,'r')
for i in inp:
url=i.strip()
#print url
checkVul(url)
print "All Done!"
Execution result:

0x03 Defense
Upgrade Magento to the latest version (2.0.6). Download link: https://www.magentocommerce.com/download
References