☰
一个缺失的零:Payy 跨链桥如何接受伪造的 ZK 证明,损失 192 万美元
2026/10/23 10:39:07 网站建设 项目流程

一个缺失的零:Payy 跨链桥如何接受伪造的 ZK 证明,损失 192 万美元

2026 年 9 月 24 日,Payy 在攻击者从其以太坊跨链桥合约中提走 1,918,793.198148 USDC 之后,暂停了整个网络。

最容易讲的故事是私钥被盗。但公开证据指向别处。

Payy 已经排除了私钥泄露、社会工程攻击,以及链下基础设施被利用的可能。攻击者提交了一笔背后没有任何资金支撑的提现,并附上一份伪造的零知识证明。Payy 的验证器接受了这份证明。随后又发生了两笔金额较小的伪造提现。

根因是 Barretenberg 小群 IPA 检查中的一个可靠性漏洞。所谓可靠性漏洞,就是让验证器接受一个虚假陈述的证明。这个检查没有强制某个累加和从零开始。用软件的话说,验证器约束不足。

这才是真正的问题所在。不是“ZK 坏了”,而是一条约束缺失、部署的验证器版本过旧、升级路径又太慢。

攻击路径,一步一步看

  • Payy 的余额是隐私 note。一笔交易最多花费两个输入 note,创建最多两个输出 note,链上只公开它们的承诺,也就是代表隐藏 note 的指纹。
  • burn 是 Payy 对“提现到以太坊”的术语。它还会公开金额、以太坊收款地址和 burn hash,而电路会把 burn hash 设为第一个输入承诺。
  • 第一笔伪造 burn 公开了四个零承诺、一个零 burn hash、金额 1,828,589,378,132 个基础单位,以及攻击者地址。USDC 有六位小数,所以这是 1,828,589.378132 USDC。
  • 四个零承诺就是填充 note,价值为零。交易前后 note 树的根完全一样。
  • 如果直接拿这笔交易去跑 Payy 的 burn 电路,它会失败。价值守恒断言会拒绝一笔由零价值 note 支撑的正数提现。note 类型断言也会失败,因为填充 note 的类型是 0,而消息里写的是 USDC note 类型。
  • Payy 不会对每笔提现重跑电路,它依赖的是“这笔交易遵守了电路规则”的密码学证明。
  • 攻击者的伪造证明通过了 Payy 的验证器。Payy 的事后报告把这一点归因于 Barretenberg 的可靠性漏洞。
  • Payy 节点内部的验证器首先接受了这份证明。Payy 的第一层聚合电路会用编译进电路的同一个 Barretenberg 验证器再验证每笔交易证明,而上层只验证下一层。出于隐私考虑,没有任何一层能看到 note 的价值,所以没有任何一层能独立重做余额检查。
  • Payy 自己的 prover 提交了这批交易,Payy 的验证者签了名,rollup 合约随后支付了 burn。
  • rollup 合约没有“burn hash 必须非零”这条规则。这条规则本可以拦下这三条消息,但它治不了验证器的病。

时间线与损失

  • 第一笔伪造 burn 在 UTC 03:25:41 被记录在 Payy 自己的链上,高度 33,195,439。
  • UTC 04:21:23,Payy 的 prover 在以太坊区块 26,044,909 中结算了这批交易。跨链桥把 1,828,589.378132 USDC 转给 0xAa4985dBDaBfACa344237D40F7E06C4a0BB57E70。八分钟内资金进入第二个钱包,并卖出换成约 683 ETH。
  • UTC 04:21:47,一个 burn 垫付方支付了攻击者的第二笔 burn,金额 1 USDC。垫付方会提前支付提现,等提现结算后再收回款项。跨链桥大约五小时后偿还了它。这两笔转账里只有一笔是损失。
  • UTC 09:30:35,第二笔结算在区块 26,046,439 中向同一地址支付了 90,202.820016 USDC,并偿还了垫付方。
  • 总损失:1,828,589.378132 + 1 + 90,202.820016 = 1,918,793.198148 USDC。
  • UTC 14:04,Payy 宣布遭到攻击,并暂停所有网络交易。
  • Payy 追踪到第一笔付款中的 682.3 ETH 在 9 月 26 日通过 19 笔存款进入 Tornado Cash。截至 10 月 11 日,最后那笔 90,202.82 USDC 仍在收款钱包里。
  • 截至 10 月 11 日,没有任何公开信息表明 Payy 已恢复运行。其公开仓库默认分支自 6 月 24 日以来没有新提交。

代码问题:一个方程,两个未知数

公开的重构资料描述了 Payy 在提交 2f95947f 中 burn 分支的四条断言。关键逻辑简化后大致如下。这不是生产环境的精确代码,但能看出检查的形状。

// Payy burn 分支的简化模型,非生产代码。fnburn_circuit(inputs:[Note;2],outputs:[Note;2],msg:BurnMsg){letinput_value=inputs[0].value+inputs[1].value;letoutput_value=outputs[0].value+outputs[1].value;assert_eq!(input_value,output_value+msg.value);// 价值守恒assert_eq!(msg.burn_hash,inputs[0].commitment);// burn 绑定第一个输入assert!(msg.value>0);// 提现金额为正assert_eq!(inputs[0].kind,msg.kind);// note 类型匹配消息}

用四个填充 note 时,input_value和output_value都是零。正数的msg.value会通不过价值守恒。类型断言同样会失败。在这个故事里,电路本身不是弱点,验证器才是。

Barretenberg 的小群 IPA 检查会对一个累加和做记账。prover 声称一组数字的总和,验证器要在看不到这些数字的情况下确认总和。规则很简单:

  • 累加和必须从零开始。
  • 每个值必须等于前一个值加上列表中的下一个数。
  • 最后一个值必须等于声称的总和。

旧检查把前两条规则揉进了一个方程。一个方程无法钉死两个未知数。恶意 prover 可以让累加和从错误的值开始,并让第一步错出恰好抵消的量。之后每一步都是诚实的,之后每个值都偏离同一个常数,总和也一样。这个常数由 prover 选,总和也就由 prover 选。

简化模型能看出这个漏洞。

// 简化模型,不是 Barretenberg 的实际代码。// 旧检查把两个义务揉进了一个等式。fnold_check(a:&[Fr],t:&[Fr],s:Fr,g:Fr)->bool{a[0]+g*(a[1]-a[0]-t[0])==Fr::ZERO&&(1..t.len()).all(|i|a[i+1]==a[i]+t[i])&&a[t.len()]==s}// 正确的检查:起始条件单独约束。fnfixed_check(a:&[Fr],t:&[Fr],s:Fr)->bool{a[0]==Fr::ZERO&&a[1]==a[0]+t[0]&&(1..t.len()).all(|i|a[i+1]==a[i]+t[i])&&a[t.len()]==s}

旧验证器在这个步骤里检查 prover 承诺值的四条断言。修复后的代码检查五条,多出来的就是a[0] == 0。

效果不是表面功夫。一个 Lean 4 模型用 Lean 4.34.1 和 Mathlib 验证了两条性质。文件里没有sorry,除 Lean 三条标准公理外不依赖其他公理。定理和证明一共 57 行。第一条定理表明,当位置数至少为二且g != 0时,旧检查对任意数字列表和任意声称的总和都会接受错误的总和。第二条表明,当g != 1时,修复后的检查会强制被接受的总和等于真实总和。

这并不能证明整个 Barretenberg 是可靠的。它只证明了一个检查在两条假设下的性质:承诺把 prover 绑定到一个累加和上,且抽查都得到诚实回答;在随机点上通过一个组合方程,意味着除可忽略概率外,三条等式在每个位置都成立。这两条假设把模型和部署的验证器连起来,但它们不覆盖 C++ 代码、Solidity 验证器、Payy 的电路或 Payy 的合约。不过它们说明,按模型来看旧检查确实会接受错误总和,而修复移除了那一个自由度。

Payy 的复现包包含那份 16,256 字节的证明和公开输入。根据事后报告,这份证明在 Payy 当时运行的构建下能通过验证。把公开的 burn hash 从 0 改成 1,同一份证明就会失败。这确认了验证器会拒绝被修改的输入,但并不能说明攻击者最初是怎么构造出那份证明的。

修复为什么没进生产

  • Payy 把服务锁定在标记为3.0.0-manual.20251030的 Barretenberg 构建上,配套 Noir1.0.0-beta.14。Cargo.toml里有一行、六个 Dockerfile 都指向这个构建。
  • Aztec 在 2026 年 5 月 14 日提交了修复。提交号58bf73a,标题fix!: small subgroup ipa bug (#44)。它改了 34 个文件,包括原生验证器、递归验证器和 Solidity 验证器模板。
  • Aztec 在 2026 年 7 月 13 日的 v5.0.0 中发布了修复。6 月的两个 v5 候选版本不含此修复,6 月 27 日发布的 v4.4.0 也不含。
  • Payy 被攻击发生在 2026 年 9 月 24 日,距离含修复的公开版本发布已经 73 天。
  • Payy 在 2026 年 5 月 15 日部署了一个新的链上验证器,用来修复自己某个电路里的另一个严重漏洞。合约创建时间比 Aztec 提交记录的时间晚 20 小时 49 分钟。事件报告说,这个合约仍由 2025 年 10 月的构建生成。一次全新的部署,用的还是旧验证器。
  • Payy 最后一次公开仓库同步在 6 月 24 日,仍指向 2025 年 10 月的构建。5 月 15 日部署的验证器到 9 月 24 日仍在使用。
  • 对 Payy 这样的 rollup 来说,升级 Barretenberg 不是改一行依赖那么简单。修复落在一个新的大版本里。验证器被编译进 Payy 的每一层聚合电路,电路、验证密钥和链上验证器合约必须一起重新生成、一起重新部署。
  • Aztec 3 月 10 日的安全公告说,修复会打包进 v5 发布;对于需要新验证密钥的严重漏洞,会先通知其网络门户(portal)的运营者,再在论坛发公告。Payy 不在那个网络上,它把 Noir 和 Barretenberg 当作库,用在自己的 rollup 上。一个库如果有厂商网络之外的用户,就需要一条能触达这些用户的渠道。
  • 到 7 月 13 日,修复已经公开,提交还给协议文档加了一节,明确写出这种伪造方式。但 Aztec 仓库里没有任何安全公告点名这个漏洞。10 月 8 日,Aztec 针对另一个 Barretenberg 验证器漏洞发布了两份安全公告,并建议“直接使用 Barretenberg 的用户”打补丁。直接用户需要的是事前通知,不是事后通知。

该改什么

  • 升级到包含58bf73a或之后提交的 Barretenberg 版本。重新生成电路、验证密钥和链上验证器合约,把它们作为一次协调好的发布一起重新部署。效果:补上缺失的约束,恢复“被接受的总和必须等于真实总和”这条性质。
  • 不要把合约层的burnHash != 0检查当成修复。它能拦下这三条消息,但验证器仍然会接受伪造证明。把它当纵深防御加上去,而不是根因治理。
  • 给单笔提现加上限,对大额提现加延迟。效果:一份伪造证明无法在一次结算里掏空整个跨链桥,团队也有时间暂停和调查。Aztec 3 月 10 日的公告给其网络门户建设者的也是这个建议。
  • 给依赖项建立验证器变更监控。对你编译进生产的每一套证明栈,都要读上游的安全提交和发布说明。锁版本可以,但别锁完就不管。效果:可靠性修复会变成一张可执行的工程工单,而不是出事之后才看到的博客文章。
  • 保持一条能在几天内完成的电路、密钥和验证器合约重建路径。如果某个严重验证器修复需要新大版本和协调重部署,那就在需要之前先演练一遍。效果:上游发布到生产部署之间的窗口会缩短。
  • 对关键验证器检查使用形式化模型。57 行的 Lean 模型本身发现不了这个漏洞,但它把检查必须满足的性质写清楚了。对旧检查来说这条性质是假的;对修复后的检查来说,在给定假设下它成立。效果:代码评审有了一个精确的问题要回答,而不是“证明系统大概没问题”的模糊感觉。
  • 在隐私允许的地方加独立不变量。非零 burn hash、每批最大 burn 额度、验证器版本白名单,这些都不是可靠性的替代品,而是爆炸半径控制。效果:它们限制单次验证器失效能造成的后果。

总结

Payy 这件事的核心不是私钥被盗。它是一个验证器因为少了一条约束而接受了虚假陈述,也是一个生产系统在修复公开 73 天后仍在运行 2025 年 10 月的构建。

缺的那一行很小,流程上的失败很大。如果你的系统接受证明,你跑的不是电路,你信的是验证器。把验证器当生产安全代码对待:盯住上游每一次变更,保持重部署路径随时可用。还要记住,在证明系统里,一个缺失的零可以值 192 万美元。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询