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 能防止第三方在传输途中篡改数据,却不能阻止用户修改自己发送的请求。
正确的支付流程
支付流程可以按以下方式设计:
- 客户端只提交商品、数量或由服务器生成的订单号。
- 服务器验证用户身份及订单归属。
- 服务器从数据库读取商品价格、优惠规则、运费和税费。
- 服务器计算最终金额,并将金额固定在订单记录中。
- 服务器使用数据库保存的订单金额调用 gateway API。
- 收到 gateway 回调后,服务器验证回调签名。
- 服务器主动查询 gateway 中的交易状态。
- 确认订单号、金额、币种和状态全部匹配后,再更新订单状态。
- 使用事务和幂等控制,防止重复回调导致重复发货或重复记账。
浏览器跳转到 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
只要有一项不一致,就不能自动发货,也不能将订单标记为已支付。
备注:内容仅供参考。
