文章总结: 本文分析了Base链上Veil_01_ETH资金池攻击事件,根源在于Groth16验证器密钥配置错误致使delta等于gamma。该漏洞破坏了零知识证明的完备性,攻击者无需witness即可伪造证明,通过调整C值绑定任意公共输入,循环提取了2.9ETH。文章纠正了早期分析,明确指出了利用验证器配置缺陷伪造证明的核心技术机制,属于典型的密码学实现漏洞。 综合评分: 91 文章分类: 漏洞分析,区块链安全,实战经验,应急响应
通过错误配置的验证密钥伪造 zkSNARK 证明:Veil_01_ETH 攻击事件
haidragon haidragon
安全狗的自我修养
2026年2月25日 15:35 湖南
官网:http://securitytech.cc
在这篇分析中,我们将还原 Base 网络上私有资金池 Veil_01_ETH(面额 0.1 ETH) 被攻击的技术机制。
#
- 位于
0x1e65c075989189e607ddafa30fa1a0001c376cfd的 Groth16 验证器存在 delta 参数与 gamma 相同(delta == gamma) 的问题。 - 这破坏了 Groth16 校验的“完备性(soundness)”:只要有一个有效证明,就可以在不知道 witness(见证数据)的情况下,将其改造为适用于任意公共输入的证明。
- 攻击者循环执行该方法 29 次,使用伪造的 nullifierHash 值
0xdead0000 … 0xdead001c,每次提取 0.1 ETH。 - 总损失:2.9 ETH。
更新(2026 年 2 月 23 日):关于基于 nullifier 推理的更正
自从发布本文后,我获得了更多证据,因此重新审视了我对重复 nullifierHash 模式的解读。
最初我基于观察到的链上 trace,将此次攻击描述为“通过迭代 nullifier 实现”——但这种解释是不完整的(并且可能具有误导性)。
更清晰且有更强证据支持的解释在这里:
https://github.com/DK27ss/VeilCash-5K-PoC
为了透明起见,我保留了原始分析内容,但请将标记为 “Outdated(已过时)” 的部分视为历史记录,而非当前结论。
攻击由如下 29 次调用组成:
{
"[OPCODE]":"CALL",
"from": {
"address":"0x5f68ad46f500949fa7e94971441f279a85cb3354",
"balance":"0"
},
"to": {
"address":"0xd3560ef60dd06e27b699372c3da1b741c80b7d90",
"balance":"100000000000000000"
},
"value":"0",
"[RAW_INPUT]":"0x2aa4eeec04c3a500ebabf33cb389589097d70d72e7da2641f59924d503dd32f8986e033627cc773923ecbf8039ed7a32fcdb39ff00073d5b46ef3ece16cf5e41d7ca586b0e6290586b3f9fc82df25610571699d2f2e009269579380ab5305e5fb4b48a32271c721edfd38936f2dd33a719b8021123b8bf2725082d0f3e2741f21736d6ed1856fcac1ef0f5687297f5c636dbd2474e21c949d8207435bbdef3a810c67bef2434fc734e6224e9e8c28b82de680a3334587f1b2cab1333ad46e285f939e53e171f05ee6cb88b53561cf08bc183c89fe67f27387d63eccf1b1fd8aca001de4c2b721b2b43b47d960602cb78f60a22e5b54bbca0eb7b25be6b308972a13e4c172e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c06300000000000000000000000000000000000000000000000000000000dead001c00000000000000000000000049a7ca88094b59b15eaa28c8c6d9bfab78d5f903000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"gas": {
"gas_left":1969887,
"gas_used":260136,
"total_gas_used":9030113
}
}
函数选择器:
0x2aa4eeec
在 Veil_01_ETH 合约中查找对应签名:
castsig"withdraw(uint256[2],uint256[2][2],uint256[2],bytes32,bytes32,address,address,uint256,uint256)"
# 0x2aa4eeec
/**
* @dev 从合约中提取存款。`proof` 为 zkSNARK 证明数据,input 是电路的公共输入数组
* `input` 数组包括:
* - 合约中所有存款的 Merkle 根
* - 唯一存款 nullifier 的哈希,用于防止双花
* - 资金接收地址
* - 可选手续费(通常支付给中继者)
*/
function withdraw(
uint256[2] calldata _pA,
uint256[2][2] calldata _pB,
uint256[2] calldata _pC,
bytes32 _root,
bytes32 _nullifierHash,
address _recipient,
address _relayer,
uint256 _fee,
uint256 _refund
) external payable nonReentrant {
require(_fee <= denomination, "Fee exceeds transfer value");
require(!nullifierHashes[_nullifierHash], "The note has been already spent");
require(isKnownRoot(_root), "Cannot find your merkle root"); // 确保使用的是最新根
require(
verifier.verifyProof(
_pA,
_pB,
_pC,
[
uint256(_root),
uint256(_nullifierHash),
uint256(uint160(_recipient)),
uint256(uint160(_relayer)),
_fee,
_refund
]
),
"Invalid withdraw proof"
);
nullifierHashes[_nullifierHash] = true;
_processWithdraw(_recipient, _relayer, _fee, _refund);
emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee, block.timestamp);
}
因此,安全性依赖于两点:
nullifierHashes[_nullifierHash]—— 防重放保护verifier.verifyProof(...)—— 与公共输入绑定的 zkSNARK 证明
漏洞出现在第二点。
攻击时的公共输入是什么样?
每次 withdraw:
_root相同_recipient相同_relayer = address(0)_fee = 0_refund = 0
只有 _nullifierHash 变化:
0xdead0000
0xdead0001
…
0xdead001c
示例(第一次 withdraw)
(此处为原文的 cast calldata-decode 输出,保持不变)
现在查看 verifier(0x1e65c075989189e607ddafa30fa1a0001c376cfd)中的 verifyProof 参数:
(此处为原始 _pA、_pB、_pC、_pubSignals 数值块,保持不变)
??? 0x00000000000000000000000000000000000000000000000000000000dead0000 ?????
这里有一个奇怪之处:
_nullifierHash = 0x000...dead0000
然后在每次 withdraw 调用中,_nullifierHash 递增 +1。
对比两次 verifyProof 调用的 calldata,我注意到证明几乎没有变化:
_pA相同_pB相同_root相同_recipient相同_fee/_refund相同
唯一变化:
_nullifierHash每次 +1_pC(一个全新的点)
因此,攻击者并没有重新生成完整 Groth16 证明,而是对现有证明进行了修补。
这是如何发生的?
此处建议阅读 Groth16 文章以理解原理:
https://alinush.github.io/groth16
经典 Groth16 校验:
e(A,B)=e(α,β)+e(∑ajICj,γ)+e(C,δ)e(A, B) = e(\alpha, \beta) + e(\sum a_j IC_j, \gamma) + e(C, \delta)e(A,B)=e(α,β)+e(∑ajICj,γ)+e(C,δ)
其中:
- e 是 BN254 上的双线性配对
- alpha ∈ G1,beta/gamma/delta ∈ G2
- vk_x 是 IC 点与公共输入线性组合得到的 G1 元素
更正
我之前忽略了一点:
不仅 _pA 和 _pB 相同,而且它们被设置为:
_pA = alpha
_pB = beta
(来自公开验证密钥)
于是:
e(A,B)=e(α,β)e(A,B) = e(\alpha,\beta)e(A,B)=e(α,β)
推导后得到:
e(vkx,γ)+e(C,δ)=0e(vk_x,\gamma) + e(C,\delta) = 0e(vkx,γ)+e(C,δ)=0
由于:
delta == gamma
于是:
e(vkx,δ)=−e(C,δ)e(vk_x,\delta) = -e(C,\delta)e(vkx,δ)=−e(C,δ)
攻击者只需要设置:
C=−vkxC = -vk_xC=−vkx
即可通过验证。
更新后的结论
关键问题并不是“通过递增 nullifier 重绑定证明”。
真正的漏洞在于:
Groth16 验证器被部署时,gamma2 == delta2。
这从根本上破坏了 pairing 校验的 soundness。
验证器不再强制证明与公共输入绑定。
攻击者可以:
- 选择任意 nullifierHash
- 从公共输入计算 vk_x
- 设置 C = -vk_x
- 调用 withdraw()
Outdated(已过时部分)
(此处保留原文关于线性补偿的数学推导与 Python 验证代码块,保持不变)
链上 trace 还显示,攻击者通过预编译地址:
- 0x07(G1 加法)
- 0x06(标量乘法)
来动态计算 IC 线性组合与 C 的修补值。
(此处为原始 STATICCALL trace 区块,保持不变)
结论
当 Groth16 验证器中:
gamma == delta
则可以通过线性修改 C 来对新的公共输入进行“重绑定”。
攻击中,攻击者:
- 构造 nullifierHash = 0xdead0000 … 0xdead001c
- 为每个 nullifierHash 计算新的 _pC
- 调用 withdraw() 29 次
- 在未进行任何真实存款的情况下提取 2.9 ETH
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:安全狗的自我修养 haidragon haidragon《通过错误配置的验证密钥伪造 zkSNARK 证明:Veil01ETH 攻击事件》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论