Aztec Connect 黑客攻击深度剖析:结算边界绕过漏洞
2026 年 6 月 14 日,一名攻击者在一个原子交易中,从已废弃的 Aztec Connect RollupProcessor 合约(地址 0xff1f2b4adb9df6fc8eafecdcbf96a2b351680455)中盗走了约 219 万美元。该合约自 2024 年 3 月起就已废弃,且管理密钥已被销毁,变得不可变,无法打补丁。
这起事件堪称一堂教科书式的安全课,告诉我们:L1 验证逻辑与零知识证明承诺之间哪怕只有一丝一毫的不一致,都可能酿成灾难性的资金损失。下面我们就来拆解到底发生了什么、为什么会发生,以及如何防范。
根本原因:结算边界不匹配
漏洞的根源在于两个关键组件之间的结构性空隙:
- L1 结算循环的遍历范围
- ZK 公开输入的哈希承诺范围
在正常的安全模型下,我们假设每个公开输入槽要么在 L1 合约层得到验证,要么被 ZK 电路约束为 publicValue == 0。而这次攻击恰恰利用了 numRealTxs 和 decoded_slots 之间的缺口。
关键点在于:numRealTxs 直接从 calldata 偏移量 4516 处读取,链上没有任何约束 。而 decoded_slots 为了满足 SHA256 预编译的数据布局,被向上取整到 numTxsPerRollup 的倍数。这个取整操作制造出了一个"空隙区域",攻击者可以随意填充。
SHA256 预编译覆盖了全部 32 个槽(8192 字节 = 32 × 256B),并且这些空隙槽也被 ZK 证明所承诺。然而,L1 结算循环只遍历第一个槽。空隙槽 2...32 在 L1 层根本没有得到任何验证。而 ZK 电路中本应对这些空隙槽的 publicValue 约束为 0,但要么被绕过了,要么压根儿就没施加。
这种双路径的分叉就是漏洞的核心:同样的 calldata 被两条路径消费,但它们的上界不同。ZK 证明认了 32 个槽,而 L1 结算逻辑只认 1 个槽。
攻击步骤详解
第一阶段:铸造 --- 凭空产生 L2 余额(Rollup #13277~13283,共 7 次)
攻击者 EOA(0x0f18d8b44a740272f0be4d08338d2b165b7edd17)调用了主入口合约(0x06f585f74e0da633ae813a0f23fb9900b61d0fcd),使用选择器 0x6f3ce701。主合约依次将调用分发给三个 Relayer 合约,每个 Relayer 都硬编码了恶意的 Rollup calldata。
每次 calldata 的关键参数:
numRealTxs = 1,rollupSize = 1024,numInnerRollups = 32- 槽 1 (L1 可见):
proofId = 0(空操作),publicValue = 0 - 槽 2~32 (31 个空隙槽,L1 不可见):
proofId = 1(存款),publicValue = N,publicOwner = 攻击者的 L2 地址
每次调用都附带了相应的 ZK 证明,而该电路并未约束空隙槽的 publicValue 为 0。
Relayer 合约依次调用了 RollupProcessor.processRollup():
- Relayer A 处理了 Rollup #13277~13281(5 次)
- Relayer B 处理了 Rollup #13282~13283(2 次)
每次调用内部发生了什么:
- Verifier 成功验证了 ZK 证明 ------ SHA256 承诺覆盖了全部 32 个槽
- L1 结算循环的
end = 1 × TX_PUBLIC_INPUT_LENGTH = 1个槽,只处理了空操作 - 空隙槽 2...32 中的伪造存款虽然被 ZK 提交进了新的 Merkle 根,但 L1 完全没检查
于是,攻击者的 L2 余额累积了 7 × 31N 的未经审核的充值,而 L1 资金池纹丝未动。
第二阶段:提款 --- 变现离场(Rollup #13284~13290,共 7 次)
攻击者利用 7 个提款 Rollup 将全部虚增的 L2 余额兑换为 L1 资产:
- Rollup #13284:270,513.054 DAI
- Rollup #13285:167.890 wstETH
- Rollup #13286:4,873.857 yvDAI
- Rollup #13287:16.570 yvWETH
- Rollup #13288:9,273.734 LUSD
- Rollup #13289:359.047 yvLUSD
- Rollup #13290:908.987 ETH
这个单笔原子交易成功执行,gasUsed = 4,513,539。合约层面的操作无法部分回滚。
代码分析:问题出在哪
Decoder.sol 中的隐患
numRealTxs 直接从 calldata 偏移量读取,链上没有任何校验。这等于把信任完全交给了 calldata ------ 合约假定 calldata 能准确反映真实交易数量。
而取整得到 decoded_slots 的过程本身就制造了隐式缺口。合约默认 ZK 电路会处理超出 numRealTxs 的槽,但这个假设在电路不强制约束时是不安全的。
solidity
// 简化示意:Decoder 中读取 numRealTxs
uint256 numRealTxs = uint256(calldata[4516:4524]); // 无任何检查
uint256 decoded_slots = ((numRealTxs + numTxsPerRollup - 1) / numTxsPerRollup) * numTxsPerRollup;
RollupProcessorV3.sol 中的结算循环
结算循环只遍历 numRealTxs 个槽,而不是 decoded_slots。这意味着超出 numRealTxs 的槽:
- 被纳入了 SHA256 承诺(因为 SHA256 处理了所有
decoded_slots) - 被 ZK 证明验证(理论上)
- 但未被 L1 合约的结算逻辑验证
solidity
// 简化示意:L1 结算循环
for (uint256 i = 0; i < numRealTxs; i++) {
// 只处理真实交易,忽略空隙槽
processPublicInput(i);
}
// SHA256 却已经覆盖了 decoded_slots 个槽
根本问题在于:L1 合约把空隙槽的验证完全外包给了 ZK 电路,却没有设置备用检查机制。
SHA256 预编译的行为
SHA256 预编译会处理全部 32 个槽,不管 numRealTxs 是多少。这本身没问题 ------ 预编译不知道交易边界。但跟 L1 循环的有限范围一结合,就形成了验证盲区。
正确的修复方案
修复方案 1:让结算循环与 SHA256 承诺范围对齐
问题 :L1 结算循环只覆盖 numRealTxs 个槽,而 SHA256 覆盖所有 decoded_slots。
修复 :结算循环必须遍历 所有 被 ZK 证明承诺的槽,而不只是 numRealTxs。循环上界应为 decoded_slots 或与之相等的派生值,确保与 SHA256 输入大小一致。
效果:这样一来,每个影响状态根的槽都会被 L1 合约独立验证,缺口被彻底堵死。
注意:这会增加 Gas 成本,但安全需要代价。另一种思路是在计算 SHA256 承诺之前,先验证空隙槽已归零。
修复方案 2:在链上校验 numRealTxs
问题 :numRealTxs 直接来自 calldata,没有任何约束。
修复 :从实际交易数据结构中推导 numRealTxs,而不是盲目信任 calldata。或者强制要求 numRealTxs 必须等于非零公开输入槽的数量。
效果:攻击者无法再操纵空隙大小。
修复方案 3:在 ZK 电路中强制约束空隙槽
问题 :电路没有约束空隙槽的 publicValue 为 0。
修复 :在电路中添加明确约束,确保任何超出 numRealTxs 的槽,其 publicValue == 0。
效果 :堵上电路层的验证缺口。但 仅凭此还不够 ------ L1 合约仍然必须验证所有被承诺的槽。
修复方案 4:L1 对 ZK 公开输入进行二次校验
问题:L1 合约完全依赖 ZK 电路来验证空隙槽。
修复:L1 合约应当独立检查每一个被 ZK 证明承诺的公开输入槽是否满足合约不变量。具体包括:
- 遍历所有被承诺的槽
- 验证存款槽对应着真实的待处理存款
- 验证提款槽背后有真实的 L2 余额支撑
效果:形成纵深防御。即使 ZK 电路有漏洞,L1 合约也能作为兜底验证者。
修复方案 5:废弃合约的资产迁移
问题:合约已废弃且不可变,但仍存有遗留用户资产。
修复:在放弃管理权限之前,项目团队应当:
- 将所有资产迁移到新合约
- 设置充足的提款窗口,并提前公告
- 考虑引入"断路器",能在紧急情况下冻结操作
- 如果可能,保留更新验证密钥或暂停提款的机制
效果:废弃合约不应存有大量价值。若发现攻击向量,必须留有应对手段。
Aztec 2.0 的类似事件
值得一提的是,这不是孤立事件。2026 年 6 月 17 日,攻击者利用另一个但概念相似的漏洞,从原始 Aztec 2.0 Rollup 合约中盗走了约 220 万美元。
在那次事件中,逃生舱电路接受了一笔没有真实存款支撑的提款。根本原因同样是两个本应强制匹配的值被解耦了:一个是证明资金所有权的成员根,另一个是针对实时链验证的结算根。两者被错误地分离开来。
攻击者针对伪造的账本证明了所有权,却在 L1 上提交了真实的账本。两项检查各自独立通过,合约却无从察觉其中的不一致。
给开发者的意见
-
永远不要盲目信任 calldata。 像
numRealTxs这样的参数必须在链上验证或推导,而非直接采信。 -
L1 合约是最终仲裁者。 不要将关键验证完全外包给 ZK 电路,必须部署备用验证逻辑。
-
对齐至关重要。 L1 结算循环的上界必须严格与 ZK 公开输入哈希承诺的范围保持一致。
-
废弃阶段同样是安全高风险期。 在放弃管理密钥之前,务必确保合约已清空或采取了妥善的防护措施,否则会成为攻击者的绝佳目标。
-
纵深防御。 多个验证层(L1 合约、ZK 电路、SHA256 承诺)应当互为冗余,而非互补。如果某一层失效,其他层必须能捕获问题。
-
测试边界情况。 当
numRealTxs不等于decoded_slots时会怎样?当空隙槽包含非零值时会怎样?这些恰恰是攻击者会反复试探的场景。
结论
Aztec Connect 事件揭示了一个区块链安全的基本准则:层间一致性不容妥协。 当 L1 合约与 ZK 证明系统对"哪些槽算数"存在分歧时,攻击者就会乘虚而入。
这 219 万美元并非通过什么复杂的密码学破解被盗,而是被一个本该在设计评审阶段就揪出的逻辑缺口所吞噬。教训再清楚不过:凡事验证,不信任何输入,并确保你的验证循环覆盖范围与承诺范围精确吻合。
对于持有遗留资产的废弃合约,教训同样冷酷:如果无法更新,那就清空它。