Dujiao-Next支付竞态漏洞:订单已支付,余额却被退回

一个已经付清的订单,为什么还会退款?

在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为例,支付创建流程与网关结果处理将一次在线支付分为三个阶段:

  1. 在数据库事务中锁定订单,检查它仍然待支付,创建状态为initiated的支付记录。
  2. 提交事务,然后向外部支付网关发起创建请求。
  3. 网关返回后,写入支付链接和支付状态;如果失败,则执行补偿。

把外部网络调用放在数据库事务外,本身不是错误。问题在于:第一阶段验证过的事实,到了第三阶段不一定仍然成立。

当服务端等待网关返回时,订单和支付记录都可能被其他请求推进。后续处理必须重新确认当前状态,而不能把第一次读到的内存对象当作仍然有效的事实。这里的实际竞争窗口是本地创建事务提交后、网关结果处理完成前的一段时间,不是整个HTTP请求耗时;PoC测得的请求往返时间只是估计值。

分支一:失败补偿退还了另一笔支付的余额

以v1.5.1为例,两个请求按以下顺序交错执行:

危险的地方在于,A起初使用的是纯在线支付,根本没有扣除这笔钱包余额。失败补偿时读到的钱包金额来自B,但A把它当作自己需要回滚的占款退了回去。

修复前的失败补偿代码虽然重新锁定并读取了订单,却没有验证订单是否仍然待支付,也没有验证当前支付尝试是否已被替代。随后调用的ReleaseWalletBalance会退还订单当前记录的钱包支付金额,并更新金额分配字段,但不会把已支付订单恢复成未支付。

因此,仅仅“退款时加了锁”并不能解决问题。锁保证了操作的串行执行,却没有保证这个请求还有资格执行退款。

假设钱包初始余额为100.00,订单金额为50.00:

状态钱包余额订单状态wallet_paid_amountonline_paid_amount
初始100.00待支付0.0050.00
钱包支付完成50.00已支付50.000.00
旧请求错误补偿后100.00仍为已支付0.0050.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的源码目录中,针对测试实例运行:

1
2
3
4
5
6
go run ./scripts/poc/payment-create-race \
  -base-url http://127.0.0.1:8080 \
  -user-token "$TEST_USER_TOKEN" \
  -product-id 1 \
  -attempts 1 \
  -delay 50ms

将base-url替换为测试实例地址,TEST_USER_TOKEN设置为测试账号的令牌,product-id替换为测试商品ID。多规格商品还需要指定-sku-id,需要固定支付渠道时使用-channel-id。示例固定延迟且只尝试一次,便于控制测试产生的订单和扣款,不保证命中。

PoC的核心操作是对同一订单依次发起两个相互重叠的支付请求:

  1. 调用POST /api/v1/payments,指定在线渠道,设置use_balance: false。
  2. 在第一个请求结束前,再调用相同接口,设置use_balance: true,不指定在线渠道。
  3. 等待请求结束,读取订单详情、钱包余额及钱包流水。

如果省略-delay,脚本会先创建一个探测订单,估算在线支付请求耗时,并尝试取消探测订单,然后按估算值选择延迟。探测订单的取消是尽力而为,测试后仍应检查是否存在未清理订单。默认尝试次数为6,本文示例将其收紧为1。

即使没有命中漏洞,钱包支付也可能已经真实买下了订单,PoC不会自动撤销这笔购买。

如何判定结果

错误退款分支应同时检查:

  • 钱包支付确实成功,而不是仅发送了请求。
  • 订单保留已支付时间及相应业务状态。
  • 钱包出现与这个订单对应的退款流水,备注包含“在线支付创建失败”。
  • 在没有其他资金变动的前提下,最终钱包余额回到支付前的数值。

当前PoC会比较钱包余额,并统计最近100条流水中匹配备注的退款数量。

成功分支需要区分“返回了旧链接”和“旧记录被恢复”。脚本看到“钱包已经付清,同时在线请求返回了链接”就会报告命中,但在线请求也可能先正常结束,随后钱包支付才作废旧记录。仅凭最后读到的响应,无法确定服务端实际发生了哪一种顺序。

因此,对成功分支应进一步核对服务端时序及支付记录,确认P1是在被作废后重新变成pending,替代标记被覆盖。本文使用下述可控网关测试确认这一点,而不把PoC的VULNERABLE字样当作唯一证据。

固定交错顺序,观察最终状态

为了排除真实网关延迟和网络抖动的影响,本次使用修复提交中的回归测试验证:

  • v1.5.1:仅移入修复提交中的测试文件,不修改业务代码。测试网关在返回前执行钱包支付,固定两个操作的交错顺序。
  • v1.5.2:运行原有回归测试,验证修复后的行为。

v1.5.1上,两项测试首先在“旧请求应返回订单已支付,而不是错误或支付链接”的断言处失败。为继续观察数据库最终状态,仅在临时测试副本中把这个提前终止的断言改成日志,保留后续资金及支付状态断言,得到以下结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
迟到失败:
balance=100.00 status=paid paid=true
wallet_paid=0.00 online_paid=50.00 refunds=1
P1: status=failed superseded=false
P2: status=success

迟到成功:
balance=50.00 status=paid paid=true
wallet_paid=50.00 online_paid=0.00 refunds=0
P1: status=pending superseded=false
P2: status=success

以上是本地测试日志的字段摘录,两种情形分别使用独立测试数据,初始余额均为100.00,订单金额均为50.00;并非真实支付网关的交易凭证。

在v1.5.2上,以下六项回归测试全部通过:

回归场景预期行为
钱包付清后,旧网关请求失败钱包仍扣除50.00,不发生错误退款
钱包付清后,旧网关请求成功旧支付保持作废,不返回旧支付链接
新混合支付已占用余额,旧请求失败不退还新支付尝试占用的余额
存在更新的支付尝试,旧请求迟到成功不用旧请求替代新请求
新混合支付进行中,旧支付收到失败回调不释放新尝试的余额
充值成功回调先到,创建请求后报错不将已经到账的充值改回失败

小结

这个漏洞不是传统意义上的“金额参数可以篡改”,也不是支付回调验签缺失。两个请求单独看都合法,问题出在它们跨越外部网关调用后,以错误的顺序组合在了一起。

一次支付创建失败,只能补偿这次尝试仍然拥有的状态;一次支付创建成功,也不意味着它现在仍有权推进订单。

数据库事务保证一段操作原子执行,但不会自动保证跨事务、跨网络调用的业务前提一直成立。对资金路径而言,重新读取状态只是第一步,更重要的是重新判断:此时此刻,这个请求还是否有权修改这笔钱和这张订单。

修复建议

受影响用户请升级到v1.5.2或包含该修复的后续版本。

Built with Hugo
Theme Stack designed by Jimmy