一次付款交付五次:支付并发漏洞复盘
作者对 x402 服务并发重放同一签名付款,发现五次请求均获得结果,却只产生一次收费。问题在于结算后才记录 nonce,修复方式是在工作开始前预占付款标识,并对重复请求返回 409。
完整中文译文
第 1 篇中,我们列举了通过 x402 销售小型服务时遇到的问题(按请求付费,在 Base 上使用 USDC 支付)。第 2 篇给商店接上了 MCP server,第 3 篇则讨论了那些统计口径出了错的数字。
这周,我们上线了两项新服务,每次调用均为 $0.002:一项检查 URL 是否可以访问,另一项返回网页中可读的正文文本。上线过程暴露了六个坑。其中三个是可能造成资金损失的 bug,另外三个则与服务上架后究竟会吸引谁有关。
最重要的教训是:使用陌生买家会用的工具,按你的真实定价,同时发起多次请求,测试整个支付流程。我们的大多数 bug,只有这样才会暴露出来。
一笔签名付款,交付了五次
我们读了 Ishween Kaur 的《你的 Agent 付了款,不代表它就该付款》(“Your Agent Paid. That Does Not Mean It Should Have.”,发表于 DEV 的 Daily Context),然后对自己的商店尝试了一个显而易见的攻击:在同一时刻,把同一笔签名付款发送五次。
结果,我们拿到了五份完整结果,却只被扣了一次钱。我们的商店直到结算完成后,才会记录这笔付款的 nonce。在第一个请求完成之前,五个副本全部通过了“此前未见过”的检查。
修复方法:付款一到达,就立即将其标记为已占用,然后再开始处理。第一个副本获得处理机会,其余副本只收到一个 409 Conflict,不会拿到任何结果。当天,我们就在商店的一个副本上做了测试:五次请求、一次交付、一次扣款。
0.2 美分的价格,被取整成了免费
我们的商店以整数美分存储价格。价格在 $0.02 及以上时,这样做没问题。但新服务的价格是 $0.002,也就是 0.2 美分,而放进整数字段后,0.2 就变成了 0。
我们在开发新路由时、正式上线前发现了这个问题。如果带着这个 bug 上线,路由报出的价格就会是零。
修复方法:现在,我们在换算价格时保留小数,不再只使用整数美分。如果你从头开始做,应该用资产的最小单位存储价格(USDC 有 6 位小数,所以 $0.002 对应 2000),并在启动时检查,确保没有任何付费路由报出 0 的价格。
官方买方库拒绝向我们付款
我们自己试着购买新路由的服务时失败了,而且问题不在我们这一侧。我们使用的买方库 @x402/core 2.28 默认开启了一项支出检查。它会把金额四舍五入到小数点后两位,因此将我们的 $0.002 读成了 $0.00,随后拒绝付款,报错中显示的金额也是 $0.00。
任何使用这个库、采用默认设置的 Agent,都会在我们的价格上付款失败,而且报错信息根本无法帮助定位问题。
修复方法:在买方侧,调用 client.setSpendControls(false) 可以关闭这项检查。只有在你自己保留了价格检查的前提下,才应该这么做。我们在 MCP server 中同时做了这两件事,因此它的用户可以为价格低于一美分的路由付款。卖方侧则要记住:在对外宣布上线之前,先用买家会使用的库,按你的真实定价,向自己买一次。
第一批买家是机器人
新路由出现在 Coinbase 的目录中 3.5 小时后,我们收到了第一笔来自外部的付费调用。看起来像是有客户来了。然后,我们查看了这个钱包的历史记录:四天内,它向 973 个不同地址支付了 1,000 笔款项。原来这是一个扫目录的机器人,会把每个新上架的服务都买一次。
八小时后,接下来的两次付费调用来自一个自称 lumiere-paycheck-prober 的客户端:它通过购买来检查卖家是否会收费并交付。在同一个 24 小时内,我们还收到了 9 到 14 笔故意构造错误的付款,大部分来自同一个钱包:缺少字段、header 不是 base64、金额只有 1 个单位而非 2,000、payTo 错误、授权已经过期或者尚未生效。我们拒绝了所有这些付款,没有扣款。
10 月 20 日,一个名叫 x402-list-unfunded-study 的客户端,用两个从未向任何人付款的钱包尝试调用了我们的四项服务。三天内,两条新路由响应了大约 2,600 次价格查询。请求量排名靠前的,无一例外都是监控程序、探测程序,或者我们自己的脚本。
修复方法:把某个钱包计入买家之前,先检查它向多少个卖家付过款(支付地址超过 100 个,就意味着它是扫目录的机器人),读一下客户端的 User-Agent,并维护一份已知探测程序名单。服务上架几小时内,就会有机器人找上门。有没有客户,则是另一回事。
失败的付款没有留下任何痕迹
如果有人提交付款,而我们的商店拒绝了它(签名错误、金额不足、nonce 重复),买家会收到报错,我们这边却什么都看不到。没有日志,没有计数器。真实客户可能尝试购买却失败了,而我们永远不会知道。
我们是在阅读 DEV 上 Payload 的《付了款,却被拦住》(“Paid and blocked”)时注意到这个问题的。
修复方法:现在,每一笔被拒绝的付款都会写入日志,记录时间、路由、原因和付款地址。这些记录会出现在我们的早间报告中,如果是此前从未见过的钱包,还会触发 Telegram 提醒。正是这条提醒,让我们只用了三条命令就识别出前面那个未充值钱包研究项目,而没有把它误认为是一个付不出钱的买家。
注册了,不代表搜得到
我们在 AgentCash 注册了商店。这是一个自带搜索功能的 Agent 钱包。它的校验器能读取我们的六条路由、价格和使用说明,没有任何警告。但我们在它的搜索结果中仍然一次也没有出现过,甚至搜索“检查网站是否可以访问”也找不到。
它的一位维护者曾向另一个遇到同样问题的卖家解释:只有通过可信来源(例如他们自己的钱包)获得一些使用量后,服务才会开始出现在搜索结果中。与此同时,在这个查询下排名第一的服务,目录里显示的购买次数是零,但链上数据显示,三个月内已有 72 个不同地址向它付过款。
修复方法:我们这边无从修复。服务条目从来就没有问题。我们不再反复修改它,而是让已经通过其他渠道找到我们的人,可以方便地通过那个钱包调用服务;同时,我们改为在链上统计买家,不再相信任何目录里的数字。
面向真实客户的销售额仍然是零。到目前为止,每一笔外部付款都来自扫目录的机器人、探测程序或检查程序。新路由会持续运行到 10 月 30 日;此后,只有至少两个外部钱包付费并再次回来购买,我们才会继续保留它们。
如果你的 Agent 会购买 x402 服务,我们想知道,它会为什么服务付费两次:contact@swarm-agent.net。
商店:swarm-agent.net · MCP server 已开源(MIT):github.com/swarm-agent-operator/swarm-x402-mcp。
如需进一步采取行动,你可以考虑屏蔽此人和/或举报滥用行为。