通过错误配置的验证密钥伪造zkSNARK证明:Veil_01_ETH攻击事件

admin 2026-03-03 05:43:50 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文分析了Base链上Veil_01_ETH资金池攻击事件,根源在于Groth16验证器密钥配置错误致使delta等于gamma。该漏洞破坏了零知识证明的完备性,攻击者无需witness即可伪造证明,通过调整C值绑定任意公共输入,循环提取了2.9ETH。文章纠正了早期分析,明确指出了利用验证器配置缺陷伪造证明的核心技术机制,属于典型的密码学实现漏洞。 综合评分: 91 文章分类: 漏洞分析,区块链安全,实战经验,应急响应


cover_image

通过错误配置的验证密钥伪造 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 {
&nbsp; &nbsp; require(_fee <= denomination, "Fee exceeds transfer value");
&nbsp; &nbsp; require(!nullifierHashes[_nullifierHash], "The note has been already spent");
&nbsp; &nbsp; require(isKnownRoot(_root), "Cannot find your merkle root"); // 确保使用的是最新根

&nbsp; &nbsp; require(
&nbsp; &nbsp; &nbsp; &nbsp; verifier.verifyProof(
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _pA,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _pB,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _pC,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; [
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; uint256(_root),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; uint256(_nullifierHash),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; uint256(uint160(_recipient)),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; uint256(uint160(_relayer)),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _fee,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; _refund
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ]
&nbsp; &nbsp; &nbsp; &nbsp; ),
&nbsp; &nbsp; &nbsp; &nbsp; "Invalid withdraw proof"
&nbsp; &nbsp; );

&nbsp; &nbsp; nullifierHashes[_nullifierHash] = true;
&nbsp; &nbsp; _processWithdraw(_recipient, _relayer, _fee, _refund);
&nbsp; &nbsp; 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 攻击事件》

真厉害自豪! 网络安全文章

真厉害自豪!

文章总结: 本文主要宣传某恒信息推出的某脑安全智能体产品,宣称其代码安全检测能力比肩国际前沿,并在漏洞挖掘、赛事防御等方面取得成果。文章内容以产品营销为主,缺乏
评论:0   参与:  0