PAYATHON 2026

PHP 移动支付回调仅检查服务器 IP 是否安全?

支付老李

结论

来源 IP 检查可以作为附加防线,但不应成为支付回调的唯一认证方式。

对于普通 HTTP/HTTPS 回调,攻击者通常无法仅靠伪造源 IP 完成有效的 TCP 请求,因为 TCP 连接需要完成握手并接收响应。不过,IP 白名单仍有明显局限。供应商的 IP 可能变更或被多个系统共享,服务器也可能遭到入侵,请求还可能经过代理。仅检查 IP 也无法防止回调重放、金额篡改和重复发放。

更可靠的做法是使用 HTTPS,并通过 HMAC、非对称数字签名或支付平台的订单查询接口验证回调。同时还要核对订单数据、限制请求的有效时间,并通过幂等机制避免重复入账。

为什么伪造 IP 不等于能够发送 HTTP 请求

GET 和 POST 通常运行在 TCP 之上。建立 TCP 连接需要经过三次握手:

  1. 客户端发送 SYN。
  2. 服务器向源 IP 返回 SYN-ACK。
  3. 客户端收到响应后发送正确的 ACK。

如果攻击者伪造了一个不属于自己的源 IP,服务器会把响应发往被冒用的地址。攻击者通常无法收到 TCP 序列号,也就很难建立连接并提交完整的 HTTP 请求。

因此,“伪造一个 IP,然后正常调用支付回调接口”并不像伪造某些无连接 UDP 数据包那么简单。除非攻击者能够控制相关网络路径、劫持路由、预测连接状态,或者已经进入受信任的服务器或网络,否则一般无法完成有效请求。

这只能说明简单伪造源地址并不容易,不能证明 IP 白名单足以保护支付业务。

仅检查 IP 的风险

无法证明请求代表真实交易

IP 白名单只能表明连接来自某个地址,无法证明请求对应的订单已经支付成功。如果供应商服务器、代理节点或使用同一出口地址的其他系统被利用,攻击者仍有可能构造回调。

无法防止重放

攻击者一旦获取某次合法回调的内容,就可能反复发送。即使每次请求都来自正确的 IP,只要系统没有幂等控制,用户仍可能被重复发放点数。

无法保护参数完整性

只检查 IP,无法确认 amount、order_id、user_id 等参数是否被修改。如果请求在供应商内部系统、代理链或配置错误的明文链路中遭到篡改,IP 检查无法发现异常。

IP 地址可能变化或共享

供应商可能使用负载均衡、云服务、CDN、IPv6 或多个出口地址。这些地址可能临时变化,也可能由多个租户共享。白名单包含的 CIDR 网段越大,信任范围也越大。

代理头可能被伪造

不要直接信任客户端提交的以下请求头:

X-Forwarded-For
X-Real-IP
Forwarded
Client-IP

如果应用直接暴露在公网,这些请求头通常可以由请求方任意填写。只有在请求确定经过自己管理的反向代理,并且应用端口无法绕过代理直接访问时,才能按照明确的代理配置解析这些字段。

推荐的验证流程

收到回调后,可以按以下顺序处理:

  1. 只接受 HTTPS 请求。
  2. 限制允许的 HTTP 方法,例如只允许 POST。
  3. 检查实际连接的来源 IP,将其作为辅助过滤条件。
  4. 验证 HMAC 或非对称数字签名。
  5. 核对订单号、商品、币种、金额、商户号和支付状态。
  6. 检查时间戳与随机数,拒绝过期或已经使用的请求。
  7. 通过支付平台的服务端 API 主动查询订单状态。
  8. 在数据库事务中执行幂等入账。
  9. 保存回调原始内容和验证结果,供审计与排查使用。

如果供应商既不提供签名,也没有订单查询接口,应要求其提供固定 IP 列表、IP 变更通知机制和重试规则。对于具有实际价值的虚拟货币或点数,仅靠 IP 白名单通常不足以控制风险。

PHP 中正确获取来源 IP

应用没有经过反向代理时,可以使用 REMOTE_ADDR:

<?php

$remoteIp = $_SERVER['REMOTE_ADDR'] ?? '';

$allowedIps = [
    '203.0.113.10',
    '203.0.113.11',
];

if (!in_array($remoteIp, $allowedIps, true)) {
    http_response_code(403);
    exit('Forbidden');
}

不要直接把客户端传入的代理头当作来源地址:

<?php

// 不安全:攻击者可以自行设置该请求头。
$remoteIp = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';

如果使用 Nginx、负载均衡器或 CDN,应在代理层配置可信代理范围,由代理统一处理真实地址,并禁止外部流量绕过代理直接访问 PHP 服务。具体配置取决于实际网络架构,PHP 本身无法自动判断哪些代理头可信。

使用 HMAC 验证回调

普通 MD5 摘要不等同于安全签名。如果方案只是 md5(参数拼接),安全性会受到拼接规则和密钥处理方式的影响,还可能出现歧义、重放或其他设计问题。更合适的方式是使用带密钥的 HMAC,例如 HMAC-SHA-256。

<?php

$secret = getenv('PAYMENT_WEBHOOK_SECRET');
if ($secret === false || $secret === '') {
    http_response_code(500);
    exit('Webhook secret is not configured');
}

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

$expectedSignature = hash_hmac('sha256', $rawBody, $secret);

if (!hash_equals($expectedSignature, $receivedSignature)) {
    http_response_code(401);
    exit('Invalid signature');
}

签名应根据收到的原始请求体计算。不要先解析 JSON 再重新序列化,否则字段顺序、空白或转义方式发生变化时,签名可能不一致。签名格式、编码方式以及是否包含时间戳,都必须严格遵循供应商协议。

如果供应商只能提供 MD5 验证,需要确认计算过程是否包含只有双方知道的密钥。没有密钥的 MD5 只是校验和,任何能修改参数的人都可以重新计算。即使包含密钥,也不能自动解决重放和重复入账问题。

防止重复发放

支付回调可能因网络超时被供应商重复发送,因此订单处理必须具备幂等性。可以在数据库中为供应商交易号建立唯一约束:

CREATE UNIQUE INDEX uq_payment_provider_transaction
ON payments (provider, provider_transaction_id);

处理回调时,应在事务中锁定本地订单,并确认订单尚未入账:

<?php

$pdo->beginTransaction();

try {
    $stmt = $pdo->prepare(
        'SELECT status, expected_amount, currency
         FROM orders
         WHERE order_id = ?
         FOR UPDATE'
    );
    $stmt->execute([$orderId]);
    $order = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($order === false) {
        throw new RuntimeException('Unknown order');
    }

    if ($order['status'] === 'paid') {
        $pdo->commit();
        http_response_code(200);
        exit('OK');
    }

    if (
        $order['expected_amount'] !== $paidAmount ||
        $order['currency'] !== $currency
    ) {
        throw new RuntimeException('Payment details do not match');
    }

    // 这里还应确认签名有效,并按供应商协议查询实际支付状态。

    $update = $pdo->prepare(
        "UPDATE orders
         SET status = 'paid', provider_transaction_id = ?
         WHERE order_id = ?"
    );
    $update->execute([$providerTransactionId, $orderId]);

    $credit = $pdo->prepare(
        'UPDATE users SET credits = credits + ? WHERE user_id = ?'
    );
    $credit->execute([$credits, $userId]);

    $pdo->commit();

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

金额不宜使用浮点数比较。更稳妥的方式是使用最小货币单位的整数,例如将 10.25 元表示为 1025 分。如果使用定点小数,数据库和应用都应按照明确的精度处理。

最低可接受方案

如果供应商确实只支持 IP 白名单,可以采取以下补偿措施:

  • 严格限定供应商公布的单个 IP 或最小 CIDR 范围。
  • 使用 HTTPS 并验证证书,不接受明文 HTTP。
  • 通过服务端订单查询接口再次确认支付状态。
  • 只处理本地已经存在的订单,不允许回调临时指定任意用户和奖励数量。
  • 核对订单金额、币种、商品和商户标识。
  • 对交易号设置唯一约束,确保重复回调不会造成重复发放。
  • 记录来源 IP、请求时间、订单号和处理结果。
  • 在请求频率异常、金额不符或订单未知时触发告警。
  • 与供应商建立 IP 变更通知流程,避免临时扩大白名单。

如果供应商连订单查询接口也不提供,可以先将交易标记为”待确认”,等到通过对账文件或供应商后台确认后,再发放高价值资产。IP 检查可以拦截大量普通伪造请求,但不能完整证明支付的真实性。

备注:内容仅供参考。