PAYATHON 2026

Prestashop 1.6 结账时出现 Amount Tampering

支付小周

结论

问题出在支付模块的订单绑定逻辑,单靠 “保持 Session” 无法彻底解决。

跳转到 HDFC 支付页面时,购物车金额是 400 USD。支付回调到达后,模块却重新读取了已被修改的购物车,并按 700 USD 和两件商品创建订单。正确的处理方式是:

  1. 跳转到 HDFC 前,在服务器端冻结本次结账的商品、币种和金额。
  2. 为本次支付生成唯一的支付引用。
  3. 收到支付回调后,根据该引用读取冻结的数据,不能读取浏览器中的当前购物车。
  4. 严格验证 HDFC 返回的支付状态、签名、币种和实际支付金额。
  5. 只有实付金额与订单金额完全一致,才能将对应订单标记为已付款。
  6. 如果金额不一致,应转入人工审核或支付异常状态,不能生成正常的已付款订单。

由于缺陷位于 HDFC 提供的 PrestaShop 模块中,通常需要模块开发方修改代码,后台配置无法可靠解决。

问题为什么会发生

复现过程中存在两个不同的状态:

时间点支付页面金额当前购物车金额
首次跳转 HDFC400 USD400 USD
新标签页添加商品后400 USD700 USD
支付回调到达时已支付 400 USD700 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 或模块供应商:

  1. 支付请求必须绑定不可变的 PrestaShop 订单或结账快照。
  2. 回调不能使用当前 Session 中已经发生变化的购物车创建订单。
  3. 必须验证银行响应签名。
  4. 必须比较订单金额、实付金额和币种。
  5. 金额不一致时不能使用 PS_OS_PAYMENT。
  6. 回调必须具备幂等性,并保存唯一交易号。

修改完成后,应重新测试原来的双标签页场景,同时增加修改数量、删除商品、应用优惠券、切换币种、重复回调、篡改金额和伪造成功状态等测试用例。

如果只有模块安装权限,没有 PHP 开发和支付接口经验,不建议直接在生产环境中修改回调代码。支付模块会影响订单状态、库存和资金核对,应由 HDFC 模块供应商提供修复版本,或交给熟悉 PrestaShop 1.6 支付模块的开发者修改并审计。

备注:内容仅供参考。