Prestashop 1.6 结账时出现 Amount Tampering
结论
问题出在支付模块的订单绑定逻辑,单靠 “保持 Session” 无法彻底解决。
跳转到 HDFC 支付页面时,购物车金额是 400 USD。支付回调到达后,模块却重新读取了已被修改的购物车,并按 700 USD 和两件商品创建订单。正确的处理方式是:
- 跳转到 HDFC 前,在服务器端冻结本次结账的商品、币种和金额。
- 为本次支付生成唯一的支付引用。
- 收到支付回调后,根据该引用读取冻结的数据,不能读取浏览器中的当前购物车。
- 严格验证 HDFC 返回的支付状态、签名、币种和实际支付金额。
- 只有实付金额与订单金额完全一致,才能将对应订单标记为已付款。
- 如果金额不一致,应转入人工审核或支付异常状态,不能生成正常的已付款订单。
由于缺陷位于 HDFC 提供的 PrestaShop 模块中,通常需要模块开发方修改代码,后台配置无法可靠解决。
问题为什么会发生
复现过程中存在两个不同的状态:
| 时间点 | 支付页面金额 | 当前购物车金额 |
|---|---|---|
| 首次跳转 HDFC | 400 USD | 400 USD |
| 新标签页添加商品后 | 400 USD | 700 USD |
| 支付回调到达时 | 已支付 400 USD | 700 USD |
有问题的模块很可能在回调中执行了类似的逻辑:
$cart = new Cart((int) $cartId);
$amount = $cart->getOrderTotal(true, Cart::BOTH);
$this->validateOrder(
(int) $cart->id,
(int) Configuration::get('PS_OS_PAYMENT'),
$amount,
$this->displayName
);
这里的 $cart 是可变数据。客户在另一个标签页添加商品后,同一个购物车的金额已经变成 700 USD。模块随后按照购物车的最新内容创建订单,却没有核对银行实际收取的 400 USD。
这是典型的 TOCTOU(Time-of-check to time-of-use)问题。发送支付请求时检查的是一个状态,付款完成后使用的却是另一个状态。
推荐方案:先创建待支付订单,再跳转支付
在 PrestaShop 1.6 中,较稳妥的做法是在跳转到支付网关前创建订单,并将订单置为 “等待 HDFC 付款” 状态。发送给 HDFC 的金额从该订单中取得,支付回调也只更新这笔订单。
这样一来,即使客户随后修改购物车,也不会影响已经创建的待支付订单。
整体流程如下:
购物车确认
↓
服务器计算金额
↓
创建“等待 HDFC 付款”的订单
↓
保存订单 ID、金额、币种和支付引用
↓
将相同金额发送给 HDFC
↓
HDFC 服务器回调
↓
验证签名、状态、订单引用、币种和金额
↓
金额一致:订单改为已付款
金额不一致:订单改为支付异常
创建待支付订单
以下代码只是 PrestaShop 1.6 的实现示意。订单状态 ID、HDFC 请求字段和签名方法必须根据实际模块和银行接口调整。
<?php
$context = Context::getContext();
$cart = $context->cart;
$customer = new Customer((int) $cart->id_customer);
$currency = new Currency((int) $cart->id_currency);
if (!Validate::isLoadedObject($cart) || !$cart->id_customer) {
throw new PrestaShopException('Invalid cart');
}
$amount = (float) $cart->getOrderTotal(true, Cart::BOTH);
$paymentReference = bin2hex(openssl_random_pseudo_bytes(16));
$pendingStateId = (int) Configuration::get('HDFC_OS_AWAITING_PAYMENT');
$this->validateOrder(
(int) $cart->id,
$pendingStateId,
$amount,
$this->displayName,
null,
array(
'transaction_id' => $paymentReference,
),
(int) $currency->id,
false,
$customer->secure_key
);
$orderId = (int) $this->currentOrder;
$order = new Order($orderId);
创建订单后,最终金额应从订单本身取得,不能继续依赖购物车:
$orderAmount = (float) $order->total_paid;
$currency = new Currency((int) $order->id_currency);
$gatewayRequest = array(
'merchant_order_id' => (string) $order->id,
'payment_reference' => $paymentReference,
'amount' => number_format($orderAmount, 2, '.', ''),
'currency' => $currency->iso_code,
);
如果 HDFC 接口要求使用 paise、cents 等最小货币单位,应按照接口文档进行转换,不能自行假设金额格式。
回调中必须验证的内容
不能因为浏览器跳转到了 “支付成功” 页面,就认定付款已经成功。模块必须处理 HDFC 服务器发送的回调或状态查询结果,并至少验证以下内容:
- HDFC 返回数据的签名或 MAC。
- 商户订单号是否存在。
- 支付引用是否属于该订单。
- 交易状态是否确实为成功。
- 实付金额是否等于订单应付金额。
- 返回币种是否等于订单币种。
- 该交易号是否已经处理。
- 订单是否仍处于允许付款的状态。
回调中不能使用客户当前的 Context::getContext()->cart,也不能依赖当前 Cookie 中的购物车 ID。回调请求可能根本不会携带原浏览器的 Session,而且 Session 本身也无法证明支付金额正确。
安全读取订单
<?php
$orderId = (int) Tools::getValue('merchant_order_id');
$gatewayTransactionId = (string) Tools::getValue('transaction_id');
$returnedAmount = (string) Tools::getValue('amount');
$returnedCurrency = (string) Tools::getValue('currency');
$paymentStatus = (string) Tools::getValue('status');
$returnedSignature = (string) Tools::getValue('signature');
$order = new Order($orderId);
if (!Validate::isLoadedObject($order)) {
http_response_code(400);
exit('Unknown order');
}
$currency = new Currency((int) $order->id_currency);
这些字段名仅作示例,必须替换为当前 HDFC 接口实际使用的参数。
先验证签名
签名算法以及参与签名的字段,必须严格按照 HDFC 提供的接口文档处理。下面的算法只是示意,不能直接代替银行规范:
<?php
$payload = $orderId
. '|'
. $gatewayTransactionId
. '|'
. $returnedAmount
. '|'
. $returnedCurrency
. '|'
. $paymentStatus;
$expectedSignature = hash_hmac(
'sha256',
$payload,
Configuration::get('HDFC_SECRET_KEY')
);
if (!hash_equals($expectedSignature, $returnedSignature)) {
http_response_code(400);
exit('Invalid signature');
}
如果 HDFC 使用加密响应、校验和、证书签名或专用 SDK,应调用银行提供的验证方法,不能自行改用 HMAC-SHA256。
正确比较金额
不要直接用浮点数进行金额安全判断:
if ((float) $returnedAmount == (float) $order->total_paid) {
// 不推荐
}
浮点数存在精度问题。比较前,应根据币种精度将金额转换为最小货币单位,再比较整数。对于使用两位小数的币种,可以这样处理:
<?php
function amountToMinorUnit($amount, $precision = 2)
{
$normalized = number_format((float) $amount, $precision, '.', '');
return (int) round((float) $normalized * pow(10, $precision));
}
$expectedMinor = amountToMinorUnit($order->total_paid, 2);
$paidMinor = amountToMinorUnit($returnedAmount, 2);
if ($paidMinor !== $expectedMinor) {
// 转入支付金额异常状态,不得标记为已付款
}
更严谨的实现可以使用字符串转换金额,避免先转换为浮点数:
<?php
function decimalToMinorUnit($amount, $precision = 2)
{
$amount = trim((string) $amount);
if (!preg_match('/^\d+(?:\.\d+)?$/', $amount)) {
throw new InvalidArgumentException('Invalid amount');
}
$parts = explode('.', $amount, 2);
$integerPart = $parts[0];
$decimalPart = isset($parts[1]) ? $parts[1] : '';
$decimalPart = substr(str_pad($decimalPart, $precision, '0'), 0, $precision);
return ((int) $integerPart * (int) pow(10, $precision))
+ (int) $decimalPart;
}
并非所有货币都使用两位小数。有些币种没有小数位,另一些可能使用三位。因此,实际代码应根据支付接口和订单币种确定精度。
金额一致后才能标记付款成功
所有验证通过后,才能更新订单状态:
<?php
$expectedAmount = decimalToMinorUnit($order->total_paid, 2);
$actualAmount = decimalToMinorUnit($returnedAmount, 2);
if ($paymentStatus !== 'SUCCESS') {
http_response_code(400);
exit('Payment not successful');
}
if ($currency->iso_code !== $returnedCurrency) {
// 建议记录安全日志并改为支付异常状态
http_response_code(400);
exit('Currency mismatch');
}
if ($expectedAmount !== $actualAmount) {
// 这里应更新为专门的“支付金额异常”状态
http_response_code(400);
exit('Amount mismatch');
}
$orderHistory = new OrderHistory();
$orderHistory->id_order = (int) $order->id;
$orderHistory->changeIdOrderState(
(int) Configuration::get('PS_OS_PAYMENT'),
(int) $order->id
);
$orderHistory->addWithemail();
HDFC 交易号还应保存到 OrderPayment 或模块自己的交易表中。交易号需要唯一性约束或等价检查,防止同一个回调被重复处理。
如果暂时不能在跳转前创建订单
另一种方案是在跳转到 HDFC 前保存一份不可变的结账快照,例如:
cart_id- 客户 ID
- 商品 ID 和属性 ID
- 数量
- 单价和折扣
- 运费和税费
- 币种
- 最终金额
- 随机支付引用
- 创建时间和有效期
- 快照状态
收到回调后,模块必须根据支付引用读取这份快照,并使用快照创建订单,不能重新读取已被修改的购物车。
不过,这种方案还要正确处理库存、优惠券、运费、税费、快照过期和并发请求,通常比 “先创建待支付订单” 更复杂。对于刚接触 PrestaShop 支付模块的开发者,更适合优先要求模块供应商采用待支付订单方案。
“保持 Session” 为什么不够
银行给出的 “Kindly maintain the session” 只能算一个修复方向,并不是完整方案。
即使保留 Session,以下问题仍然存在:
- 同一个 Session 中的购物车仍可能被另一个标签页修改。
- 支付网关的服务器回调通常不共享客户浏览器的 Session。
- Session ID 只能标识会话,不能证明付款金额。
- 客户可能重复提交、刷新回调或同时发起多笔支付。
- 浏览器返回参数可能被伪造,不能作为最终付款依据。
Session 可以用来辅助保存支付引用,但不能替代订单快照、签名验证和金额校验。
不建议采用的修补方式
不要只在客户进入支付页面时,通过前端禁用 “加入购物车” 按钮。攻击者仍可直接发送请求,而且多个标签页之间的状态也很难通过前端可靠限制。
也不要采用以下做法:
$paidAmount = Tools::getValue('amount');
$this->validateOrder($cartId, $paidState, $paidAmount, ...);
银行返回的金额不能直接决定订单中的商品和订单总额。正确的关系始终应该是:
银行实付金额 = 服务器端已冻结订单的应付金额
只有等式成立,订单才能进入已付款状态。
模块还应补充的安全控制
修复金额绑定问题后,还应检查以下容易遗漏的环节:
- 回调只能通过 HTTPS 接收。
- 验证签名后才能信任返回字段。
- 不要信任隐藏表单中的金额、商品名称或订单状态。
- 回调必须采用幂等处理,重复通知不能重复确认订单。
- 保存原始交易号、支付金额、币种和验证结果。
- 支付失败、少付、多付和币种不符应使用独立的订单状态。
- 浏览器成功页只负责展示结果,不能负责确认付款。
- 如果 HDFC 支持服务端交易查询,应在必要时向银行查询交易终态。
- 支付引用必须不可预测,并且只能使用一次。
- 对异常金额、无效签名和重复交易记录安全日志,但不能将密钥、完整卡号等敏感数据写入日志。
实际处理建议
应先暂停当前模块自动确认订单的功能,至少避免继续出现少付款却交付全部商品的情况。随后,将复现步骤和以下修复要求提交给 HDFC 或模块供应商:
- 支付请求必须绑定不可变的 PrestaShop 订单或结账快照。
- 回调不能使用当前 Session 中已经发生变化的购物车创建订单。
- 必须验证银行响应签名。
- 必须比较订单金额、实付金额和币种。
- 金额不一致时不能使用
PS_OS_PAYMENT。 - 回调必须具备幂等性,并保存唯一交易号。
修改完成后,应重新测试原来的双标签页场景,同时增加修改数量、删除商品、应用优惠券、切换币种、重复回调、篡改金额和伪造成功状态等测试用例。
如果只有模块安装权限,没有 PHP 开发和支付接口经验,不建议直接在生产环境中修改回调代码。支付模块会影响订单状态、库存和资金核对,应由 HDFC 模块供应商提供修复版本,或交给熟悉 PrestaShop 1.6 支付模块的开发者修改并审计。
备注:内容仅供参考。
