一个已经付清的订单,为什么还会退款?
在Dujiao-Next中,同一个订单可以选择在线支付,也可以使用钱包余额支付。用户发起在线支付后,服务端需要等待支付网关返回结果;在这段时间里,用户可能通过另一个请求,用钱包余额付清同一订单。
此时,最早的在线支付请求已经失去了继续处理订单的前提。但在修复前,它仍会根据旧状态处理网关结果,产生两种异常:
- 网关迟到失败:旧请求的失败补偿错误退还了后来那次钱包支付的余额,而订单仍然保留已支付状态。
- 网关迟到成功:旧请求把内存中的支付记录重新写入数据库,将已经作废的记录恢复成
pending,并返回支付链接。
第一种情况使商户面对一张没有净收款、却被系统认定为已支付的订单;如果后续据此交付商品,就可能产生实际损失。第二种情况则让已经用钱包付清订单的用户再次拿到在线支付链接,带来重复付款风险。本文验证了错误退款、支付记录恢复及链接返回。
问题出在订单支付的公共业务逻辑,而不是某一家支付网关的签名校验。下文从两个请求如何交错执行入手,分析失败补偿为什么会退错钱,以及成功返回为什么会覆盖已经作废的记录。
上游已通过提交cb61a4d4修复,并于2026年11月2日发布包含该修复的v1.5.2。
本文的动态验证在隔离的本地测试环境中完成,没有对第三方生产站点进行测试。PoC会创建订单并实际扣除钱包余额,只应在自有或明确获授权的测试环境使用。
哪些版本和配置受影响
受影响版本
从v0.0.5-beta到v1.5.1的36个版本均存在“迟到失败错误退还钱包余额”的漏洞路径,v1.5.2首次修复。
不安全的退款逻辑可追溯到2026年2月21日引入钱包功能的提交c21c0bfc。从v0.0.5-beta到v1.5.1,失败补偿路径均缺少对当前支付尝试和订单状态的复核;v1.5.2包含修复提交cb61a4d4。
触发条件与权限
就本文的钱包全额支付路径而言,需要:
- 一个能够正常登录、下单和支付的普通用户账号。
- 钱包余额足以覆盖测试订单的应付金额。
- 该订单允许使用一个在线支付渠道,也允许使用钱包余额支付。
- 在线支付创建已经提交本地事务,但尚未完成网关结果处理;钱包支付需要在这个窗口内完成。
- 对于错误退款分支,旧的在线支付创建还需要在钱包支付完成后返回失败。
这条路径不要求管理员权限、不要求控制支付网关,也不要求伪造支付成功回调。用户操作的始终是自己的订单。鉴权入口会检查订单归属,但这不能阻止同一用户对自己的订单发起相互交错的合法请求,见用户支付接口。
利用的难点在时序:钱包必须在在线请求等待网关结果期间完成支付,而错误退款还要求网关随后返回失败。网关响应较慢可能扩大这个窗口,但并不意味着一定能触发漏洞。没有钱包支付能力,或不能对同一订单发起在线支付的配置,不满足本文复现路径的前提。
根因:事务结束了,旧请求却还认为自己拥有订单
正常的支付创建流程
两个请求都从POST /api/v1/payments进入:在线支付指定支付渠道并设置use_balance: false,钱包全额支付设置use_balance: true,不指定在线渠道。接口检查的是用户能否支付这张订单;竞态则发生在鉴权之后的业务处理过程中。
以v1.5.1为例,支付创建流程与网关结果处理将一次在线支付分为三个阶段:
- 在数据库事务中锁定订单,检查它仍然待支付,创建状态为
initiated的支付记录。 - 提交事务,然后向外部支付网关发起创建请求。
- 网关返回后,写入支付链接和支付状态;如果失败,则执行补偿。
把外部网络调用放在数据库事务外,本身不是错误。问题在于:第一阶段验证过的事实,到了第三阶段不一定仍然成立。
当服务端等待网关返回时,订单和支付记录都可能被其他请求推进。后续处理必须重新确认当前状态,而不能把第一次读到的内存对象当作仍然有效的事实。这里的实际竞争窗口是本地创建事务提交后、网关结果处理完成前的一段时间,不是整个HTTP请求耗时;PoC测得的请求往返时间只是估计值。
分支一:失败补偿退还了另一笔支付的余额
以v1.5.1为例,两个请求按以下顺序交错执行:
危险的地方在于,A起初使用的是纯在线支付,根本没有扣除这笔钱包余额。失败补偿时读到的钱包金额来自B,但A把它当作自己需要回滚的占款退了回去。
修复前的失败补偿代码虽然重新锁定并读取了订单,却没有验证订单是否仍然待支付,也没有验证当前支付尝试是否已被替代。随后调用的ReleaseWalletBalance会退还订单当前记录的钱包支付金额,并更新金额分配字段,但不会把已支付订单恢复成未支付。
因此,仅仅“退款时加了锁”并不能解决问题。锁保证了操作的串行执行,却没有保证这个请求还有资格执行退款。
假设钱包初始余额为100.00,订单金额为50.00:
| 状态 | 钱包余额 | 订单状态 | wallet_paid_amount | online_paid_amount |
|---|---|---|---|---|
| 初始 | 100.00 | 待支付 | 0.00 | 50.00 |
| 钱包支付完成 | 50.00 | 已支付 | 50.00 | 0.00 |
| 旧请求错误补偿后 | 100.00 | 仍为已支付 | 0.00 | 50.00 |
最后一行中的online_paid_amount = 50.00不表示网关真的收到了这笔钱,只是退款辅助逻辑重新写入的金额分配。订单仍有支付时间,钱包中却已经恢复了全部余额,形成无净付款的已支付订单。它可能造成后续交付损失;具体是否已经发货,还取决于履约方式及任务执行时序,不能仅凭订单状态断言所有部署都会交付。
分支二:成功返回把已经作废的支付记录“复活”
另一种情况是A最终成功拿到了支付链接。
在v1.5.1中,B用钱包付清订单时,会把P1标记为expired,并写入superseded_at、superseded_by_payment_id等替代信息。然而A内存中的P1仍然是请求开始时的旧对象,不包含这些更新。
网关结果处理直接在旧对象上设置支付链接和pending状态,然后调用仓储更新;仓储的更新实现保存整个对象,没有在这里校验数据库中的当前支付状态。
于是,B写入的失效状态和替代信息被旧快照覆盖,P1又成了一条pending的支付记录,A还会把支付链接返回给调用方。
这个分支说明本地支付状态可能回退,旧链接可能再次暴露给用户,带来重复付款风险。**返回了支付链接、网关实际再次扣款、系统重复发货,是三个不同的结论。**本文本地测试直接验证的是前者对应的记录复活和链接返回,不声称已经完成真实网关的二次扣款或证明重复发货。
如何复现与验证
用PoC构造并发请求
PoC源码位于scripts/poc/payment-create-race/main.go,使用普通用户接口完成登录、下单、选择支付渠道、并发支付及结果检查,不需要管理员接口或数据库写权限。
测试前,准备一个同时支持在线支付和钱包支付的测试商品,以及余额足够支付该商品的专用账号。账号应独占使用,商品不应连接生产交付流程,以免其他交易干扰余额和流水判断。
在包含PoC的源码目录中,针对测试实例运行:
| |
将base-url替换为测试实例地址,TEST_USER_TOKEN设置为测试账号的令牌,product-id替换为测试商品ID。多规格商品还需要指定-sku-id,需要固定支付渠道时使用-channel-id。示例固定延迟且只尝试一次,便于控制测试产生的订单和扣款,不保证命中。
PoC的核心操作是对同一订单依次发起两个相互重叠的支付请求:
- 调用
POST /api/v1/payments,指定在线渠道,设置use_balance: false。 - 在第一个请求结束前,再调用相同接口,设置
use_balance: true,不指定在线渠道。 - 等待请求结束,读取订单详情、钱包余额及钱包流水。
如果省略-delay,脚本会先创建一个探测订单,估算在线支付请求耗时,并尝试取消探测订单,然后按估算值选择延迟。探测订单的取消是尽力而为,测试后仍应检查是否存在未清理订单。默认尝试次数为6,本文示例将其收紧为1。
即使没有命中漏洞,钱包支付也可能已经真实买下了订单,PoC不会自动撤销这笔购买。
如何判定结果
错误退款分支应同时检查:
- 钱包支付确实成功,而不是仅发送了请求。
- 订单保留已支付时间及相应业务状态。
- 钱包出现与这个订单对应的退款流水,备注包含“在线支付创建失败”。
- 在没有其他资金变动的前提下,最终钱包余额回到支付前的数值。
当前PoC会比较钱包余额,并统计最近100条流水中匹配备注的退款数量。
成功分支需要区分“返回了旧链接”和“旧记录被恢复”。脚本看到“钱包已经付清,同时在线请求返回了链接”就会报告命中,但在线请求也可能先正常结束,随后钱包支付才作废旧记录。仅凭最后读到的响应,无法确定服务端实际发生了哪一种顺序。
因此,对成功分支应进一步核对服务端时序及支付记录,确认P1是在被作废后重新变成pending,替代标记被覆盖。本文使用下述可控网关测试确认这一点,而不把PoC的VULNERABLE字样当作唯一证据。
固定交错顺序,观察最终状态
为了排除真实网关延迟和网络抖动的影响,本次使用修复提交中的回归测试验证:
v1.5.1:仅移入修复提交中的测试文件,不修改业务代码。测试网关在返回前执行钱包支付,固定两个操作的交错顺序。v1.5.2:运行原有回归测试,验证修复后的行为。
v1.5.1上,两项测试首先在“旧请求应返回订单已支付,而不是错误或支付链接”的断言处失败。为继续观察数据库最终状态,仅在临时测试副本中把这个提前终止的断言改成日志,保留后续资金及支付状态断言,得到以下结果:
| |
以上是本地测试日志的字段摘录,两种情形分别使用独立测试数据,初始余额均为100.00,订单金额均为50.00;并非真实支付网关的交易凭证。
在v1.5.2上,以下六项回归测试全部通过:
| 回归场景 | 预期行为 |
|---|---|
| 钱包付清后,旧网关请求失败 | 钱包仍扣除50.00,不发生错误退款 |
| 钱包付清后,旧网关请求成功 | 旧支付保持作废,不返回旧支付链接 |
| 新混合支付已占用余额,旧请求失败 | 不退还新支付尝试占用的余额 |
| 存在更新的支付尝试,旧请求迟到成功 | 不用旧请求替代新请求 |
| 新混合支付进行中,旧支付收到失败回调 | 不释放新尝试的余额 |
| 充值成功回调先到,创建请求后报错 | 不将已经到账的充值改回失败 |
小结
这个漏洞不是传统意义上的“金额参数可以篡改”,也不是支付回调验签缺失。两个请求单独看都合法,问题出在它们跨越外部网关调用后,以错误的顺序组合在了一起。
一次支付创建失败,只能补偿这次尝试仍然拥有的状态;一次支付创建成功,也不意味着它现在仍有权推进订单。
数据库事务保证一段操作原子执行,但不会自动保证跨事务、跨网络调用的业务前提一直成立。对资金路径而言,重新读取状态只是第一步,更重要的是重新判断:此时此刻,这个请求还是否有权修改这笔钱和这张订单。
修复建议
受影响用户请升级到v1.5.2或包含该修复的后续版本。