支付逻辑中最危险的缺陷很少只存在于一个 HTTP 请求里。它们通常出现在商城数据库、后台任务、支付服务商和 webhook 接收器之间:服务商已经受理扣款,但应用只看到了超时;同一通知重复到达,或者旧通知晚于新通知到达;退款最初看似成功,后来却失败或要求补充操作;数据库事务重试时,无法回滚的外部支付动作又执行了一次。

本文反复使用一个明确标注为虚构的案例:带月度订阅的 Northstar Shop。Northstar 在本地创建订单,向支付服务商发送付款和退款指令,在付款确认后开通订阅权限,并维护自己的财务账本。它不是客户案例,也不代表任何已经取得的财务成果。

审计不能只问“接口有没有幂等键”。更有用的问题是:在约定的重试、延迟、重复、乱序和部分失败场景中,客户会不会被扣款两次、退款会不会丢失、权限会不会发给错误的人? 审计将这些场景与必须始终成立的财务规则逐一对应。它与通用的业务逻辑安全测试和REST API 安全评估互为补充,而不是重复介绍跳步、越权和一般性竞态。

先写清财务不变量,再看正常流程状态

Northstar 有 orders、payment_attempts、订阅服务和服务商对象。一个 paid 标记掩盖了太多事实。应用至少要区分:本地已记录支付意图、服务商已受理指令、服务商已确认当前结果、本地账本已入账、权限指令已执行,以及双方记录已经完成对账。

这个虚构场景的关键不变量包括:

  • 一个结账意图最多产生一次具有经济效果的扣款;
  • 只有在客户、套餐、金额、币种和账期都匹配且得到权威确认后,才能开通订阅;
  • 重复投递可以重复处理尝试,但不能重复生成账目、发货、余额或权限;
  • 累计成功退款不得超过已捕获金额,并正确考虑部分退款、争议、撤销和服务商规则;
  • pending、requires_action、failed 或后续退回的款项,不能展示为永久完成的退款;
  • 每个服务商侧支付或退款都能关联到一个本地业务操作;无法匹配的记录进入对账异常,而不是被强行改成某个状态;
  • webhook 的成功响应表示真实消息已经持久保存,并不表示所有下游业务动作都已完成。

传输确认、服务商 API 响应、本地事务提交、发货或开通权限以及最终资金结算,是不同的事实。把它们压缩成一个 success 字段,会让中间的断点无法被发现。

左右滑动查看对照

边界事实它能证明什么它不能证明什么应保留的证据
服务商 API 响应服务商对一次请求返回了结果本地状态已经提交,或异步结果以后不会变化服务商对象 ID、请求 ID、幂等键、金额、币种、响应类别
webhook 的 HTTP 2xx接收端确认了投递账本、权限、邮件或发货已经完成事件或投递标识、签名结果、持久 inbox 记录、接收时间
本地事务提交一组本地记录原子更新外部服务商动作也随之回滚操作 ID、转换版本、outbox 记录、账目记录
对账匹配双方记录在声明规则下相符以后不会发生争议、退回或迟到事件对账期间、服务商状态、异常项、处置记录

幂等性由具体服务商契约限定

“使用幂等性”不是完整要求。审计必须固定服务商、接口、API 代际、账户与地域范围、键的保留时间、参数绑定、并发请求和错误响应语义。不同支付系统之间不存在通用的 exactly-once 承诺。

Stripe 区分 API v1 和 v2。V1 会绑定参数,并可能在重试时返回已保存的 500;键只在有限期限内保存,晚期复用可能成为新请求。V2 不重复已成功的工作,但可能继续失败或部分完成的工作并返回更新响应。因此报告必须写明版本。

Adyen 将键限定在 company account,保存 7–14 天,而且跨地域接口不做重复检查。可重试的临时错误继续使用同一键。Northstar 在这层保护不可用时选择暂停并对账,不在最不确定的时刻削弱去重控制。

应用自己的操作 ID 必须比服务商缓存活得更久。Northstar 在第一次远程调用前创建不可变的退款操作 ID,将幂等键和请求参数绑定并持久化。客服点击“重试”时,如果业务意图没有变化,就恢复同一操作。新键代表新的经济指令,必须经过明确决策。

测试还要覆盖键冲突和参数变更。同一键若对应不同金额、币种、客户、支付对象或退款原因,应在本地调用服务商之前失败。两个后台任务不能为同一个待处理操作各自生成新键。超过服务商保留窗口后,系统先查询并对账权威状态,再决定是否允许发出新指令。

把订单状态与支付状态视为相互作用的两个系统

Northstar 的订单可能经历 payment_requested、provider_unknown、provider_confirmed、entitlement_committed 和 reconciled。服务商有自己的状态变化,而且经常是异步的。两者不是简单镜像;每个本地转换都要说明由什么证据授权。

假设 Northstar 发出支付请求后超时。服务商可能尚未开始,可能已经完成但响应丢失,也可能仍在处理。直接标记 failed 是制造确定性;换新键重发则可能二次扣款。安全的本地状态应是 unknown 或 pending,并保留原操作和原键。后台任务按已固定的服务商契约重试,能查询时读取服务商对象,最终由验证后的 webhook 或对账消除不确定性。

数据库事务只能保护本地写入,不能回滚外部动作。PostgreSQL 对序列化失败要求重跑整个数据库事务,包括决定 SQL 和值的逻辑。如果事务体直接调用支付 API,SQL 重试可能再次触发数据库无法撤销的扣款。远程调用应位于自动重试事务之外,通过持久命令记录和应用级幂等边界来驱动。

事务 outbox 可以解决本地双写:业务转换与 outbox 指令在同一事务中提交,后台任务随后发送。但它不会创造全局 exactly-once。转发器仍可能重复发布,所以消费者要持久去重并让业务效果幂等。反向接收 webhook 时,inbox 起到相同作用。

从结账意图到完成对账
  1. 记录意图在同一事务中保存订单、不可变操作 ID、预期金额与币种以及 outbox 指令。
  2. 调用服务商后台任务使用已保存的参数和幂等键发送,并记录服务商请求 ID 和对象 ID。
  3. 接收结果验证 webhook,持久保存投递标识和载荷,提交成功后再确认接收。
  4. 应用转换处理器提交账本转换和权限指令;权限消费者以幂等方式执行该指令。
  5. 执行对账比较本地与服务商记录,把差异送入异常队列并保留处置轨迹。

webhook 需要持久接收、分层去重与有效状态转换

服务商会重试 webhook,也可能乱序投递。Stripe 使用事件 ID 识别同一事件的重复投递。Adyen 对同一 eventCode 和 pspReference 的消息可能包含不同日期或更新后的细节。Northstar 必须区分“同一投递再次到达”和“同一服务商对象出现真实更新”:后者仍要保存和处理,同时保证同一经济效果只应用一次。通用 payload_hash 不能替代服务商契约。

接收端先按服务商规则验证真实性,再以服务商特定的投递标识插入 inbox,保存处理所需载荷、关联字段、接收时间和验证结果。只有插入事务提交后才返回成功响应。这样既避免“先确认后崩溃导致事件永久丢失”,也避免“先做完所有业务逻辑导致超时重投”。

去重至少分为三层:

  • 投递标识和更新版本阻止重复处理同一投递,同时不会丢弃同一对象的真实新状态;
  • 业务操作 ID 阻止两个不同事件或 API 响应重复执行同一退款、续费、余额或发货;
  • 账本唯一约束在上层保护失效时,仍阻止同一经济事实重复入账。

乱序处理要依据有效转换和权威状态,不能只看抵达时间。旧的中间事件不能覆盖更新证据,但后来的真实失败或银行退回可以合法改变原先看似成功的状态。结账过期后才到达的真实付款成功也不能被忽略:系统查询当前服务商状态,再按政策履约、退款或转人工处理。

退款是独立指令,并且可能没有永久终态

退款按钮不是一条反向账目。它会创建带独立 ID、授权规则、金额上限、服务商对象、异步状态和异常处理的操作。服务商文档描述了 pending 后成功、表面成功后失败,以及某些需要银行信息的流程后来进入 requires_action。Northstar 不能根据初始响应就承诺永久完成,也不能过早发放不可逆的店铺余额。

部分退款让金额规则变成并发问题。假设一笔捕获金额为 100,两个客服同时都看到 100 尚可退,并各自提交 60。服务商也许会拒绝超额请求,但 Northstar 仍应在本地做原子预留或计算,以提供确定反馈,并避免把供应商错误当成唯一控制。计算要考虑已成功退款、待处理预留、失败或取消后释放的预留、争议与撤销,以及原币种最小单位;若业务规则涉及手续费,也应明确其计算方式。

本地指令阶段,例如 requested 和 submitted,应与服务商的退款状态分开记录。只有具体 API 定义了 pending、requires_action、succeeded、failed 或 canceled 时,才使用对应的服务商状态。可以映射成较少的内部状态,但必须明确说明规则,并保留客户沟通、可用余额和对账需要的差别。

订阅退款还涉及金钱和权限的分离。退回一次续费,并不会自动决定访问权限立即结束、周期末结束还是等待人工审核。反过来,撤销权限也不是钱已退回的证据。产品和财务负责人要先批准耦合政策,审计再用重试和延迟事件验证它。

部分失败必须进入设计好的恢复路径

对每个跨边界步骤,测试矩阵都要问:进程在该步骤之前或之后立即停止,会发生什么?重要故障点包括:本地操作提交后但尚未发送;服务商受理后但本地未记录响应;webhook 持久接收后但尚未处理;账本写入后但权限尚未应用;权限执行后但 outbox 还未标记完成。

每个故障必须进入三种受控结果之一:重试同一幂等操作;从服务商权威状态恢复;或进入有负责人和期限的异常队列。“写日志后继续”不是恢复策略。无限重试并让订单永久 pending,也不是恢复策略。

对账用来关闭在线处理无法彻底消除的间隙。Northstar 定期比较本地操作 ID、服务商对象 ID、金额、币种、状态和经济总额。输出应是一组可处置异常:服务商有扣款但本地无订单;本地显示 paid 却无服务商确认;退款总额不一致;当前状态矛盾;webhook 缺失;或者未确认付款却已经开通权限。

对账本身必须可安全重跑。纠正动作使用与在线处理相同的操作 ID 和唯一约束,并记录异常为何自动修复、升级人工处理或被接受。时间窗口应与具体支付方式一致;延迟银行支付和即时银行卡流程不能共用任意的“五分钟”阈值。

安全测试矩阵应覆盖故障点与事件组合

默认使用服务商 sandbox、合成用户、专用订单和非生产权限。任何必要的生产测试都需要单独授权。矩阵固定到服务商模式和 API 版本。测试工具可以模拟重要状态,但不能证明每条真实银行网络都具有同样行为。

左右滑动查看对照

测试类型注入条件或顺序预期证据可发现的危险捷径
客户端重试服务商受理后丢弃响应,再发送同一操作只有一个外部效果,键与参数稳定,本地最终收敛每次 HTTP 尝试都换新键
并发指令同时启动两个任务或两个退款审批只领取一次指令,或退款预留不超额无原子保护的先读后写
重复 webhook在处理前后多次投递同一事件一个投递记录和一个业务/账本效果,确认仍迅速只在内存中去重
乱序 webhook新更新先于旧中间事件到达,之后再给出有效撤销旧数据不覆盖当前证据,真实后续失败仍被应用“最后抵达者胜”或“成功永不改变”
崩溃边界每个本地或外部步骤后终止进程重启后恢复或对账,不生成新经济指令在自动重试数据库事务中调用外部 API
退款生命周期pending→成功、表面成功→失败或 requires_action客户信息、账本、预留和权限匹配当前确认状态及后续变化把初始响应当成永久终态
保留期与地域键过期后重试,或改走另一地域查询、对账或受控暂停,不假设全球去重假设键永久且全球有效
对账在合成数据中破坏本地与外部关联只产生一个有负责人的异常,纠正动作可重跑只比较控制台总额

并发请求数量和时间窗口要预先约定,不能把安全检查变成对服务商的压力测试。日志和证据不保存密钥或不必要的支付数据。在授权规则明确 sandbox、账户、回调接口、允许的故障注入以及禁止的真实资金影响时,API 渗透测试可以覆盖这些由应用负责的集成路径。

用状态证据验收报告,而不是看截图

一条支付逻辑发现应写明:违反了哪个不变量、初始状态、确切请求与事件顺序、服务商契约和版本、受影响的本地记录或账目,以及最终观察到的经济效果。观察事实要与假设后果分开。两张 200 截图无法说明最终创建了几个服务商对象、账目、退款或权限。

报告验收至少需要:

  • 每个范围内支付和退款流程的边界图与状态映射;
  • 每个受测服务商接口的幂等契约,包括范围、保留期、地域、参数变更、并发和错误语义;
  • 不变量到测试的矩阵,覆盖正常、重试、重复、乱序、并发、超时、崩溃和对账;
  • 本地操作、服务商请求与对象、webhook 投递、账本记录和权限指令之间的关联证据;
  • 可复现发现、经过清理的时间与标识,以及 sandbox 无法代表真实环境时的明确限制;
  • 对原序列和邻近变体的复测,包含事务提交边界的故障注入;
  • 带合成异常的对账结果,以及重复纠正不会产生第二次效果的证据。

“未发现重复扣款”很弱。更可检查的表述是:同一个已保存操作在约定边界被重试;服务商只有一个对象;本地账本只有一条唯一记录;测试样本的对账没有未解释重复。即便如此,结论也只适用于约定范围和时间点,不保证未来版本、所有支付方式或通用 exactly-once。

报告也不能把服务商控制当作应用控制。服务商限制退款总额,并不能证明 Northstar 给客户展示了正确状态、关联到正确订单、维持租户隔离或处理了部分失败。数据库隔离不能让远程效果加入本地事务。FIFO 队列本身也不能证明每个下游动作只发生一次。

让评估范围能够支持可复核结论

范围应列出结账与续费变体、退款与取消路径、币种、支付方式、服务商账户与地域、API 版本、webhook 格式、订阅权限规则和财务账本。测试条件包括至少两个客户身份与操作员角色、服务商 sandbox、事件重放工具、带关联 ID 的日志以及受限故障注入能力。

排除项要直接写明:除非另有授权,不对支付服务商基础设施进行渗透;不做生产压力测试和真实资金移动;sandbox 只能证明集成在该条件下的行为,不能承诺每条真实支付网络的结果。渗透测试范围规划可将这些边界整理成可测试任务,渗透测试报告示例则展示发现应具备的可复核证据。

Pentest.red 可以在授权的 API 或 Web 应用评估中检查由应用负责的支付与退款逻辑。准备工作应提供服务商和 API 版本、本地状态模型、关键财务不变量以及 sandbox 中允许的故障场景。这样测试会集中在资金与应用状态可能分叉的边界,同时不会承诺通用保证或预设财务结果。

PENTEST.RED

讨论渗透测试范围与报价

首先明确系统、访问权限、限制条件和预期证据。

准备测试范围

本文以深入研究为基础,经过编辑整理,注重清晰表达和实际应用。

PENTEST.RED / RED JOURNAL