SUPEE-7405 cart merge error fix

During our work with Magento security patch SUPEE-7405 and its patch-fixes (version 1.1) we’ve noticed an error which is logged as an exception and it can be even a source of security exploit if exception.log file is world readable. In this article we describe this bug and fixes for it.

How it appears

This bug exists if you installed the Magento security patch SUPEE-7405 released in January 2016. In order to reproduce it you just need to follow these steps:

  1. Log in as a customer and add product with custom option to shopping cart.
  2. Log out.
  3. Add the same product with the same options to shopping cart.
  4. Log in as the same customer again.

As the result, the shopping cart will contain one product and its quantity will be 2 (assuming that in both cases this product has been added to the cart with quantity 1). You will not see anything strange until check Magento logs (if it is enabled) – there you will find the errors like here:

ERR (3):
exception 'Exception' with message 'Error during unserialization' in lib/Unserialize/Parser.php:59
Stack trace:
#0 app/code/core/Mage/Core/Helper/UnserializeArray.php(44): Unserialize_Parser->unserialize('14,13,12')
#1 app/code/core/Mage/Sales/Model/Quote/Item.php(503): Mage_Core_Helper_UnserializeArray->unserialize('14,13,12')
#2 app/code/core/Mage/Sales/Model/Quote.php(1759): Mage_Sales_Model_Quote_Item->compare(Object(Mage_Sales_Model_Quote_Item))
#3 app/code/core/Mage/Checkout/Model/Session.php(216): Mage_Sales_Model_Quote->merge(Object(Mage_Sales_Model_Quote))
#4 app/code/core/Mage/Checkout/Model/Observer.php(44): Mage_Checkout_Model_Session->loadCustomerQuote()
#5 app/code/core/Mage/Core/Model/App.php(1358): Mage_Checkout_Model_Observer->loadCustomerQuote(Object(Varien_Event_Observer))
#6 app/code/core/Mage/Core/Model/App.php(1337): Mage_Core_Model_App->_callObserverMethod(Object(Mage_Checkout_Model_Observer), 'loadCustomerQuo...', Object(Varien_Event_Observer))
#7 app/Mage.php(448): Mage_Core_Model_App->dispatchEvent('customer_login', Array)
#8 app/code/core/Mage/Customer/Model/Session.php(225): Mage::dispatchEvent('customer_login', Array)
#9 app/code/core/Mage/Customer/Model/Session.php(215): Mage_Customer_Model_Session->setCustomerAsLoggedIn(Object(Mage_Customer_Model_Customer))
#10 app/code/core/Mage/Customer/controllers/AccountController.php(158): Mage_Customer_Model_Session->login('test@test.com', 'somepassword123')
#11 app/code/core/Mage/Core/Controller/Varien/Action.php(418): Mage_Customer_AccountController->loginPostAction()
#12 app/code/core/Mage/Core/Controller/Varien/Router/Standard.php(254): Mage_Core_Controller_Varien_Action->dispatch('loginPost')
#13 app/code/core/Mage/Core/Controller/Varien/Front.php(172): Mage_Core_Controller_Varien_Router_Standard->match(Object(Mage_Core_Controller_Request_Http))
#14 app/code/core/Mage/Core/Model/App.php(365): Mage_Core_Controller_Varien_Front->dispatch()
#15 app/Mage.php(684): Mage_Core_Model_App->run(Array)
#16 index.php(83): Mage::run('', 'store')
#17 {main}

So, stack trace line #8 reveals customer login credentials and that is the hotspot of the issue.

Source of the issue

As you may already know, Mage_Sales_Model_Quote_Item has been updated with SUPEE-7405 patch’s files or by Magento upgrade to version CE 1.9.2.3 or EE 1.14.2.3. As follows, the comparing logic has been changed:

-                    $_itemOptionValue = @unserialize($itemOptionValue);
-                    $_optionValue = @unserialize($optionValue);
-                    if (is_array($_itemOptionValue) && is_array($_optionValue)) {
-                        $itemOptionValue = $_itemOptionValue;
-                        $optionValue = $_optionValue;
-                        // looks like it does not break bundle selection qty
-                        unset($itemOptionValue['qty'], $itemOptionValue['uenc']);
-                        unset($optionValue['qty'], $optionValue['uenc']);
+                    try {
+                        /** @var Unserialize_Parser $parser */
+                        $parser = Mage::helper('core/unserializeArray');
+
+                        $_itemOptionValue = $parser->unserialize($itemOptionValue);
+                        $_optionValue = $parser->unserialize($optionValue);
+
+                        if (is_array($_itemOptionValue) && is_array($_optionValue)) {
+                            $itemOptionValue = $_itemOptionValue;
+                            $optionValue = $_optionValue;
+                            // looks like it does not break bundle selection qty
+                            unset($itemOptionValue['qty'], $itemOptionValue['uenc']);
+                            unset($optionValue['qty'], $optionValue['uenc']);
+                        }
+
+                    } catch (Exception $e) {
+                        Mage::logException($e);

And the source of the issue is in these lines:

/** @var Unserialize_Parser $parser */
$parser = Mage::helper('core/unserializeArray');

$_itemOptionValue = $parser->unserialize($itemOptionValue);
$_optionValue = $parser->unserialize($optionValue);

The default PHP unserialize() (with error suppressing @) has been replaced with custom Unserialize_Parser which is called from Mage_Core_Helper_UnserializeArray. Let’s check lib/Unserialize/Parser.php unserialize() method:

    /**
     * @param $str
     * @return array|null
     * @throws Exception
     */
    public function unserialize($str)
    {
        $reader = new Unserialize_Reader_Arr();
        $prevChar = null;
        for ($i = 0; $i < strlen($str); $i++) {
            $char = $str[$i];
            $arr = $reader->read($char, $prevChar);
            if (!is_null($arr)) {
                return $arr;
            }
            $prevChar = $char;
        }
        throw new Exception('Error during unserialization');
    }

It is searching for the array-encoding characters and if they are not found in the argument string, it will throw an exception. And if we give it not serialised string – it will throw an exception as well.

How to fix the problem

The solution is quite simple, check the passed string and if it is not serialised – we won’t process it. In order to make that fix, let’s create a separate extension.

Below you can see the extension declaration file app/etc/modules/Atwix_Unserialize.xml:

<?xml version="1.0" ?>
<config>
	<modules>
		<Atwix_Unserialize>
			<active>true</active>
			<codePool>local</codePool>
		</Atwix_Unserialize>
	</modules>
</config>

The extension config with Mage_Core_Helper_UnserializeArray rewrites app/code/local/Atwix/Unserialize/etc/config.xml:

<?xml version="1.0"?>
<!--
/**
 * Atwix
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is bundled with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://opensource.org/licenses/osl-3.0.php
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@magentocommerce.com so we can send you a copy immediately.

 * @category    Atwix Mod
 * @package     Atwix_Unserialize
 * @author      Atwix Core Team
 * @copyright   Copyright (c) 2016 Atwix (http://www.atwix.com)
 * @license     http://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
 */-->
<config>
    <modules>
        <Atwix_Unserialize>
            <version>1.0.0</version>
        </Atwix_Unserialize>
    </modules>
    <global>
        <helpers>
            <atwix_unserialize>
                <class>Atwix_Unserialize_Helper</class>
            </atwix_unserialize>
            <core>
                <rewrite>
                    <unserializeArray>Atwix_Unserialize_Helper_Core_Unserializearray</unserializeArray>
                </rewrite>
            </core>
        </helpers>
    </global>
</config>

The helper rewrite will check if string is serialised with borrowed from WordPress method app/code/local/Atwix/Unserialize/Helper/Core/Unserializearray.php:

<?php
/**
 * Atwix
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is bundled with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://opensource.org/licenses/osl-3.0.php
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@magentocommerce.com so we can send you a copy immediately.
 * @category    Atwix Mod
 * @package     Atwix_Unserialize
 * @author      Atwix Core Team
 * @copyright   Copyright (c) 2016 Atwix (http://www.atwix.com)
 * @license     http://opensource.org/licenses/osl-3.0.php  Open Software License (OSL 3.0)
 */

class Atwix_Unserialize_Helper_Core_Unserializearray extends Mage_Core_Helper_UnserializeArray
{
    /**
     * Borrowed from wordpress/wp-includes/functions.php
     *
     * @param $data
     * @return bool
     */
    function isSerialized($data) {
        // if it isn't a string, it isn't serialized
        if ( ! is_string( $data ) )
            return false;
        $data = trim( $data );
        if ( 'N;' == $data )
            return true;
        $length = strlen( $data );
        if ( $length < 4 )
            return false;
        if ( ':' !== $data[1] )
            return false;
        $lastc = $data[$length-1];
        if ( ';' !== $lastc && '}' !== $lastc )
            return false;
        $token = $data[0];
        switch ( $token ) {
            case 's' :
                if ( '"' !== $data[$length-2] )
                    return false;
            case 'a' :
            case 'O' :
                return (bool) preg_match( "/^{$token}:[0-9]+:/s", $data );
            case 'b' :
            case 'i' :
            case 'd' :
                return (bool) preg_match( "/^{$token}:[0-9.E-]+;\$/", $data );
        }
        return false;
    }

    /**
     * We won't unserialize string if it is not serialized
     *
     * @param string $str
     * @return array
     * @throws Exception
     */
    public function unserialize($str)
    {
        if($this->isSerialized($str)) {

            return parent::unserialize($str);
        }

        return $str;
    }
}

It is easy, but that’s all that is needed to fix the cart merge error. You can download the extension with the fix here or copy the code itself from this article.