PAYATHON 2026

PHP 集成 payment gateway 时如何防止 Amount Tampering?

支付阿杰

结论

防止 Amount Tampering 的原则很明确:支付金额必须由服务器根据可信的订单数据计算,不能使用浏览器、表单、URL、Cookie 或其他客户端数据中传来的金额。

创建支付请求时,服务器应从数据库读取订单金额,再提交给 payment gateway。支付完成后,服务器还要验证 gateway 的签名,并通过服务端 API 查询交易结果。只有订单号、金额、币种和支付状态全部一致,才能将订单标记为已支付。

Amount Tampering 为什么会发生

如果支付页面包含下面的字段,攻击者可以修改请求内容后再提交:

<input type="hidden" name="amount" value="999.00">

隐藏字段并不安全。攻击者可以使用浏览器开发者工具或代理软件,也可以直接构造 HTTP 请求,将 999.00 改为 0.01。

下面的写法也有同样的问题:

$amount = $_POST['amount'];
$gateway->createPayment([
    'amount' => $amount,
]);

客户端传来的 amount、price、currency、discount、order_total 等字段都不可信。HTTPS 能防止第三方在传输途中篡改数据,却不能阻止用户修改自己发送的请求。

正确的支付流程

支付流程可以按以下方式设计:

  1. 客户端只提交商品、数量或由服务器生成的订单号。
  2. 服务器验证用户身份及订单归属。
  3. 服务器从数据库读取商品价格、优惠规则、运费和税费。
  4. 服务器计算最终金额,并将金额固定在订单记录中。
  5. 服务器使用数据库保存的订单金额调用 gateway API。
  6. 收到 gateway 回调后,服务器验证回调签名。
  7. 服务器主动查询 gateway 中的交易状态。
  8. 确认订单号、金额、币种和状态全部匹配后,再更新订单状态。
  9. 使用事务和幂等控制,防止重复回调导致重复发货或重复记账。

浏览器跳转到 success_url 并不能证明支付成功。这个页面可以被直接访问,URL 参数也能伪造。

PHP 创建支付示例

下面是一个简化示例。具体的 API 名称、签名方式和字段格式,应以所用 gateway 的官方文档为准。

<?php

declare(strict_types=1);

session_start();

$userId = requireAuthenticatedUserId();
$orderId = filter_input(INPUT_POST, 'order_id', FILTER_VALIDATE_INT);

if (!$orderId) {
    http_response_code(400);
    exit('Invalid order');
}

$pdo = getPdo();

$stmt = $pdo->prepare(
    'SELECT id, user_id, amount_minor, currency, status
     FROM orders
     WHERE id = :id'
);
$stmt->execute(['id' => $orderId]);

$order = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$order || (int) $order['user_id'] !== $userId) {
    http_response_code(404);
    exit('Order not found');
}

if ($order['status'] !== 'pending') {
    http_response_code(409);
    exit('Order cannot be paid');
}

/*
 * amount_minor 由服务器在创建订单时计算并保存。
 * 例如人民币 99.90 元保存为 9990 分。
 */
$paymentRequest = [
    'merchant_order_id' => (string) $order['id'],
    'amount'            => (int) $order['amount_minor'],
    'currency'          => $order['currency'],
    'callback_url'      => 'https://example.com/payment/webhook',
    'return_url'        => 'https://example.com/payment/result',
];

$payment = $gatewayClient->createPayment($paymentRequest);

$stmt = $pdo->prepare(
    'UPDATE orders
     SET gateway_payment_id = :payment_id
     WHERE id = :id AND status = :status'
);
$stmt->execute([
    'payment_id' => $payment->id,
    'id'         => $order['id'],
    'status'     => 'pending',
]);

header('Location: ' . $payment->checkoutUrl, true, 303);
exit;

这里并不是通过隐藏金额来保证安全,而是从设计上取消客户端决定金额的权限。即使攻击者在请求中额外加入 amount=1,服务器也不会读取或使用这个值。

回调验证示例

收到 webhook 后,不能仅凭回调中的 status=paid 判断支付成功。服务器应先验证签名,再通过 gateway 的服务端接口查询真实的交易记录。

<?php

declare(strict_types=1);

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_GATEWAY_SIGNATURE'] ?? '';

if (!$gatewayClient->verifyWebhookSignature($rawBody, $signature)) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
$paymentId = $event['payment_id'] ?? null;

if (!is_string($paymentId) || $paymentId === '') {
    http_response_code(400);
    exit('Invalid payment ID');
}

/*
 * 不直接信任 webhook 中的金额和状态,
 * 而是通过服务端 API 获取 gateway 保存的交易记录。
 */
$transaction = $gatewayClient->getPayment($paymentId);

$pdo = getPdo();
$pdo->beginTransaction();

try {
    $stmt = $pdo->prepare(
        'SELECT id, amount_minor, currency, status, gateway_payment_id
         FROM orders
         WHERE gateway_payment_id = :payment_id
         FOR UPDATE'
    );
    $stmt->execute(['payment_id' => $paymentId]);

    $order = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$order) {
        throw new RuntimeException('Unknown payment');
    }

    $isValid =
        hash_equals((string) $order['gateway_payment_id'], (string) $transaction->id)
        && (int) $order['amount_minor'] === (int) $transaction->amount
        && hash_equals((string) $order['currency'], (string) $transaction->currency)
        && $transaction->status === 'paid';

    if (!$isValid) {
        throw new RuntimeException('Payment verification failed');
    }

    /*
     * 条件更新提供幂等保护:
     * 同一个 webhook 重复到达时,不会重复处理已支付订单。
     */
    $stmt = $pdo->prepare(
        "UPDATE orders
         SET status = 'paid', paid_at = CURRENT_TIMESTAMP
         WHERE id = :id AND status = 'pending'"
    );
    $stmt->execute(['id' => $order['id']]);

    if ($stmt->rowCount() === 1) {
        // 在这里记录一次性的履约任务,避免直接重复发货。
    }

    $pdo->commit();
    http_response_code(200);
    echo 'OK';
} catch (Throwable $e) {
    $pdo->rollBack();

    error_log($e->getMessage());
    http_response_code(400);
    echo 'Verification failed';
}

verifyWebhookSignature()、createPayment() 和 getPayment() 都是示意名称,不能据此认定某个 SDK 一定提供这些方法。实际实现必须使用 gateway 规定的签名算法、请求字段,以及证书或密钥管理方式。

金额计算要避免浮点数

不要使用 float 比较或累计金额,因为二进制浮点数可能出现精度误差。更稳妥的做法是使用最小货币单位的整数:

$unitPriceMinor = 1990; // 19.90
$quantity = 3;
$totalMinor = $unitPriceMinor * $quantity; // 5970

如果业务必须处理小数字符串,可以使用 BCMath,同时明确该币种的小数位规则:

$total = bcmul('19.90', '3', 2); // "59.70"

不同币种的小数位数并不完全相同,不能默认所有金额都乘以 100。金额处理应遵循 gateway 和对应币种的规则,订单中也要同时保存金额与币种。

签名不能替代服务端定价

部分 gateway 要求商户对金额、订单号等参数生成 HMAC。签名能够证明请求来自持有密钥的服务器,但被签名的数据本身必须可信。

先接收客户端金额,再为这个金额签名,仍然是不安全的:

$amount = $_POST['amount'];
$signature = hash_hmac('sha256', $amount, $secret);

应当先从数据库读取金额,再生成签名:

$payload = $orderId . '|' . $amountMinor . '|' . $currency;
$signature = hash_hmac('sha256', $payload, $secret);

密钥只能保存在服务器端,例如环境变量或专用 secret manager 中。不要将密钥写入 JavaScript、HTML、移动端代码或公开仓库。比较签名时应使用 hash_equals(),不能使用普通的 ==。

还需要注意的检查项

  • 商品价格、折扣、优惠券、运费和税费都要由服务器重新验证并计算。
  • 创建订单后,应保存不受客户端变化影响的应付金额。如果价格发生变化,应重新生成订单,或执行明确的重新计价流程。
  • gateway 中的 merchant order ID 应与本地订单建立唯一映射。
  • 金额和币种必须同时核对,不能只比较数字而忽略币种。
  • 验证交易所属的商户账号或收款方标识,防止接收其他商户账号下的交易。
  • webhook 应基于原始请求体验证签名。除非 gateway 文档明确要求,否则不要先修改、排序或重新编码 JSON。
  • 回调接口要能处理重复通知,并保证支付、记账和发货操作具有幂等性。
  • 对金额不一致、未知订单、重复 payment ID 和异常状态转换记录安全日志并发出告警。
  • 生产密钥和测试密钥应相互隔离,并定期轮换。
  • 不要将 CSRF token 当作金额防篡改措施。CSRF 防护仍有必要,但它解决的是另一类攻击。

判断支付是否成功时,应以服务器保存的订单为准,并验证以下约束:

local.order_id  == gateway.merchant_order_id
local.amount    == gateway.amount
local.currency  == gateway.currency
gateway.status  == paid
gateway.payee   == expected_merchant

只要有一项不一致,就不能自动发货,也不能将订单标记为已支付。

备注:内容仅供参考。