PAYATHON 2026

如何防止非支付网关向 ASP.NET 页面提交响应

支付阿杰

结论

不能只凭 Request.Form["responsecode"] 判断付款是否成功,也不能靠隐藏页面地址、检查 Referer 或限制表单来源来确认请求来自 First Atlantic Commerce(FAC)。任何人都能构造内容相同的 HTTP POST 请求。

正确的处理方式是:

  1. 按照 FAC 官方协议验证签名或消息认证码。
  2. 由服务器调用 FAC 的交易查询或验证接口,确认交易的真实状态。
  3. 核对订单号、金额、币种、商户号和网关交易编号。
  4. 通过原子操作,只把数据库中处于待支付状态的订单更新为已支付。
  5. IP 白名单只能作为附加措施,不能承担主要验证工作。

如果当前使用的 FAC 接口没有提供可验证的签名,就应以服务器之间的交易查询结果为准,不能直接信任浏览器提交到 Success.aspx 的字段。

为什么普通 POST 不可信

下面的代码只能读取客户端提交的数据:

string responseCode = Request.Form["responsecode"];

它无法证明:

  • 请求来自 FAC 服务器;
  • 请求内容没有被修改;
  • 对应交易已经成功;
  • 金额和币种与本地订单一致;
  • 同一条成功响应没有被重复提交。

攻击者不需要访问支付网关,只需向公开地址发送类似请求:

POST /Success.aspx HTTP/1.1
Content-Type: application/x-www-form-urlencoded

responsecode=00&orderId=12345

所以,responsecode 只能视为一项尚未验证的输入,不能直接用来更新付款状态。

还需注意,3-D Secure 流程中的返回请求有时由持卡人的浏览器提交,并非支付网关服务器直接发起。此时即使检查来源 IP,得到的也可能是用户或代理服务器的 IP 地址。

推荐的处理流程

可以按以下流程处理支付结果:

收到 Success.aspx POST
        ↓
读取并严格校验参数格式
        ↓
验证 FAC 签名(如果该接口提供)
        ↓
根据本地订单读取预期金额、币种和当前状态
        ↓
由服务器调用 FAC 查询/确认接口
        ↓
核对订单号、金额、币种、商户号和交易编号
        ↓
在数据库事务中将 Pending 原子更新为 Paid

Success.aspx 适合作为支付结果的接收入口或用户返回页,不应成为判断付款成功的唯一依据。

首选方案:服务器端查询交易状态

具体的接口名称和参数应以当前 FAC 商户文档为准。下面的示例不能用于推测 FAC 的真实 URL、字段名或成功码。

可以将 FAC 查询逻辑封装为一个服务:

public sealed class GatewayTransactionResult
{
    public bool IsSuccessful { get; set; }
    public string MerchantOrderId { get; set; }
    public string GatewayTransactionId { get; set; }
    public decimal Amount { get; set; }
    public string Currency { get; set; }
}

public interface IFacGatewayClient
{
    GatewayTransactionResult QueryTransaction(string gatewayTransactionId);
}

页面收到 POST 后,应先把其中的所有数据视为不可信输入:

protected void Page_Load(object sender, EventArgs e)
{
    if (!string.Equals(Request.HttpMethod, "POST",
                       StringComparison.OrdinalIgnoreCase))
    {
        Response.StatusCode = 405;
        Response.End();
        return;
    }

    string orderId = Request.Form["orderId"];
    string gatewayTransactionId = Request.Form["transactionId"];

    if (string.IsNullOrWhiteSpace(orderId) ||
        string.IsNullOrWhiteSpace(gatewayTransactionId))
    {
        Response.StatusCode = 400;
        Response.End();
        return;
    }

    PaymentOrder order = paymentRepository.GetByOrderId(orderId);

    if (order == null)
    {
        Response.StatusCode = 404;
        Response.End();
        return;
    }

    GatewayTransactionResult result =
        facGatewayClient.QueryTransaction(gatewayTransactionId);

    bool verified =
        result != null &&
        result.IsSuccessful &&
        string.Equals(result.MerchantOrderId, order.OrderId,
                      StringComparison.Ordinal) &&
        result.Amount == order.Amount &&
        string.Equals(result.Currency, order.Currency,
                      StringComparison.OrdinalIgnoreCase);

    if (!verified)
    {
        auditLogger.Warn(
            "Payment verification failed. OrderId={0}, TransactionId={1}",
            orderId,
            gatewayTransactionId);

        Response.StatusCode = 400;
        Response.End();
        return;
    }

    paymentRepository.MarkPaidIfPending(
        order.OrderId,
        result.GatewayTransactionId);

    Response.Redirect("~/PaymentComplete.aspx", false);
    Context.ApplicationInstance.CompleteRequest();
}

判断交易是否成功,依据的不是客户端提交的 responsecode,而是服务器使用商户凭据向 FAC 查询后得到的结果。

查询时,应优先使用服务器在创建支付请求时生成并保存到数据库中的标识,不要完全依赖回调提交的订单号。否则,攻击者可能替换订单号,把一笔真实的小额交易错误地关联到另一张订单。

验证支付网关签名

如果 FAC 当前接口文档规定响应中包含签名、哈希或 MAC,就必须严格按照文档指定的算法、字段顺序、字符编码和编码格式进行验证。

不能自行选择参与签名的字段,也不能擅自将算法改为 HMAC-SHA256。支付接口两端必须使用完全一致的协议。下面只是通用的 HMAC-SHA256 示例,只有 FAC 文档明确规定使用相同算法和拼接规则时,才能直接采用:

using System;
using System.Security.Cryptography;
using System.Text;

public static class SignatureVerifier
{
    public static bool VerifyHmacSha256(
        string canonicalMessage,
        string receivedHexSignature,
        string secret)
    {
        if (string.IsNullOrEmpty(canonicalMessage) ||
            string.IsNullOrEmpty(receivedHexSignature) ||
            string.IsNullOrEmpty(secret))
        {
            return false;
        }

        byte[] key = Encoding.UTF8.GetBytes(secret);
        byte[] data = Encoding.UTF8.GetBytes(canonicalMessage);

        string expected;

        using (var hmac = new HMACSHA256(key))
        {
            byte[] hash = hmac.ComputeHash(data);
            expected = BytesToHex(hash);
        }

        return FixedTimeEquals(
            expected.ToUpperInvariant(),
            receivedHexSignature.Trim().ToUpperInvariant());
    }

    private static string BytesToHex(byte[] bytes)
    {
        var builder = new StringBuilder(bytes.Length * 2);

        foreach (byte value in bytes)
        {
            builder.Append(value.ToString("X2"));
        }

        return builder.ToString();
    }

    private static bool FixedTimeEquals(string left, string right)
    {
        if (left == null || right == null)
        {
            return false;
        }

        int difference = left.Length ^ right.Length;
        int length = Math.Min(left.Length, right.Length);

        for (int i = 0; i < length; i++)
        {
            difference |= left[i] ^ right[i];
        }

        return difference == 0;
    }
}

构造 canonicalMessage 时必须遵守 FAC 文档。字段顺序、分隔符、金额的小数格式、URL 编码方式,以及使用 Base64 还是十六进制格式,任何一项不一致都会导致签名验证失败。

商户密钥必须保存在服务器端配置或密钥管理系统中,不能出现在页面、JavaScript、日志或数据库审计信息里。

数据库更新必须具备幂等性

即使请求真实有效,支付网关也可能因为超时或重试而重复发送通知,所以更新操作必须具备幂等性。

例如,只允许将 Pending 状态更新为 Paid:

UPDATE PaymentOrders
SET PaymentStatus = 'Paid',
    GatewayTransactionId = @GatewayTransactionId,
    PaidAtUtc = @PaidAtUtc
WHERE OrderId = @OrderId
  AND PaymentStatus = 'Pending';

程序还应检查受影响的行数:

int affectedRows = command.ExecuteNonQuery();

if (affectedRows == 1)
{
    // 首次成功确认
}
else
{
    // 订单可能已经处理,不要重复发货或重复记账
}

最好为 GatewayTransactionId 设置唯一约束,防止同一笔网关交易关联到多个本地订单。

除了检查 responsecode,还要确认:

  • 本地订单确实存在;
  • 当前状态为 Pending;
  • 商户订单号完全一致;
  • 支付金额完全一致;
  • 币种一致;
  • 商户账户或终端标识一致;
  • 网关交易状态为最终成功;
  • 网关交易编号此前没有绑定到其他订单。

IP 白名单只能作为补充

如果 FAC 明确提供固定的生产环境回调 IP 地址,可以在防火墙、反向代理或 IIS 层配置白名单。但白名单不能代替签名验证和服务器端查询,原因包括:

  • 3-D Secure 返回请求可能经过用户浏览器;
  • 网关的 IP 范围可能发生调整;
  • 代理、负载均衡以及错误的转发头配置可能影响来源判断;
  • IP 地址只能表明网络来源,无法证明请求内容未被修改或重放。

如果站点部署在反向代理之后,不要直接信任客户端提供的 X-Forwarded-For。只有该请求头由受信任的代理覆盖并清洗时,才可以使用。

不应依赖的防护方式

以下方式都不足以确认付款成功:

  • 检查 Referer 或 Origin;
  • 将 Success.aspx 地址保密;
  • 使用隐藏字段;
  • 检查浏览器 User-Agent;
  • 只允许 HTTP POST;
  • 使用 Session 判断用户是否刚刚发起过支付;
  • 只判断 responsecode 是否等于某个成功值;
  • 仅依赖 HTTPS。

HTTPS 能保护传输过程,但无法阻止攻击者主动向页面提交自行构造的数据。Referer、Origin 和 User-Agent 也可能缺失或被伪造。

传统 ASP.NET 应用还可能受到 SameSite Cookie 策略影响,从第三方支付页面返回时拿不到原来的 Session Cookie。因此,支付确认逻辑不能依赖 Session,而应通过服务器保存的订单号、一次性关联值和网关交易编号建立关联。

其他安全注意事项

生产环境还应采取以下措施:

  • 所有支付入口只接受 HTTPS。
  • 限制输入的长度、字符集和格式。
  • 使用参数化 SQL,防止 SQL 注入。
  • 不要在日志中记录银行卡资料、完整认证数据、密钥或未经处理的完整请求。
  • 记录验证失败、金额不一致、重复交易和状态冲突,供后续审计。
  • 对异常请求实施速率限制,但不能把限流当作身份验证。
  • 只有完成服务器端验证后,才能执行发货、充值等后续操作。
  • 对长期停留在 Pending 状态的订单运行后台对账任务,并再次向 FAC 查询交易状态。

最终原则是:浏览器返回页只负责接收通知和展示结果。订单是否已经付款,必须由可验证的签名或 FAC 服务器端交易记录来确定。

备注:内容仅供参考。