文章分析了近期在ZKP协议中发生的两次漏洞利用事件,都源于Groth16可信设置过程中缺少第二阶段贡献,导致验证密钥中的gamma和delta参数相等。这使得攻击者无需真实见证即可伪造有效零知识证明,从而在Foom和Veil协议中窃取资金。文章详细解释了该漏洞原理、Groth16验证机制以及如何构造伪造证明。

TL;DR: 过去一周发生了两起针对实时 ZK 电路已知漏洞利用事件。两者都源于相同的根本原因。它们不是微妙的约束不足的 bug,而是 Groth16 验证器(由 snarkjs 生成)的设置不正确(仅缺少最后一步)。其中一个被白帽黑客利用,获得了约 150 万美元,另一个被抽走了 5 ETH。
自 Zcash、Filecoin 以及此后许多协议部署以来,我们一直认为 ZK 相关的代码很难,这就是我们没有观察到恶意行为者进行任何漏洞利用的原因。
然而,事实证明这种直觉至少是部分不正确的。首先,对于某些协议和漏洞,我们根本不知道它们是否被利用。例如,我们不知道 Zcash 上臭名昭著的 伪造漏洞 是否曾被利用。其次,在野外发现的 ZK 协议中有许多 bug,有些根本不复杂。例如,2019 年 circomlib 中曾出现过 一个 bug (https://github.com/iden3/circomlib/commit/109cdf40567fce284dca1d535819ce28922653e0#diff-d115e3788e56ac3d62b1f243561d50e5),可能导致 Tornado Cash 被抽干。该 bug 是一个非常简单的完全无约束的信号:
outs[0] = S[nInputs - 1].xL_out;
// instead of
// outs[0] <== S[nInputs - 1].xL_out;
此外,根据我们在对 ZKP 协议进行多次审计后的经验,代码库通常确实很复杂,可能涉及复杂的数学和密码学,但我们经常发现非常简单的 bug。在我看来,这主要有三个原因。开发者对复杂部分感到焦虑,并专注于在那里尽可能地保证系统安全,但他们可能会忽略更简单的问题。几乎没有基本的工具可以帮助开发者发现简单问题,而不会让他们不堪重负地处理大量误报(且没有超时)。第三,ZK 的心智模型与普通编程不同,并且由于其底层性质,一些 ZK DSLs 很容易被误用。
不过,别误会我的意思,有时我们会在 ZK 代码中发现极其复杂的 bug,或者需要该领域深厚专业知识才能发现的微妙 bug!
长话短说,许多 bug 赏金已支付给白帽黑客以修复 ZK bug,许多协议在生产环境中运行着大量的 TVL,但迄今为止,ZKP 协议中从未记录到任何漏洞利用事件。这可能让我们感到有点过于舒适,相比之下,智能合约领域每隔几个月就会发生灾难性的漏洞利用。也许我们只是运气好?也许对黑客来说没有足够的 ROI?我们实际上不知道。我们只是想相信 ZKP 领域的工作人员非常注重安全,并且这个领域的研究人员更喜欢戴白帽。此外,也许一个著名的组织只是还没有开始研究 ZKPs……
这让我们回到了今天和 上周日发布的这条推文。你也可以在 此链接 找到来自 beacon302 的撰写文章。

昨天,duha_real(zkSecurity 团队成员)发现了这个问题,并接管了自 2025 年 6 月 27 日起生效的 Foom Heist 挑战 Bug 赏金(价值约 50 万美元)。几乎在同一时间,另一位 白帽黑客 利用了以太坊上的 Foom 合约,以防止任何恶意利用。你可以在 这里 找到来自 beacon302 的非常详细的 PoC,但它与之前的原始 bug 利用非常相似(请注意,有些网站将此问题报道为 攻击,但事实并非如此)。

那么,这里出了什么问题?这两个协议都使用了 Circom 和 snarkjs,这可能是目前最常用的 SNARKs 框架组合,尤其是 Groth16 协议(至少从部署数量来看),它需要一个可信设置。剧透一下:在这两种情况下,设置阶段 都出了大问题。
Groth16 证明依赖于一组公共参数,这些参数必须在任何人能够创建或验证证明之前生成。这个生成过程称为 可信设置。 具体来说,设置会产生密码学参数,其中包括 α、β、γ 和 δ 等特殊值。这些是源自秘密随机数(有时称为“有毒废料”)的椭圆曲线点。整个证明系统的安全性取决于这些秘密是真正随机的,然后被永久销毁。如果任何人保留它们,他们就可以伪造证明。
为了降低这种风险,设置分为两个阶段,通常作为多方计算(MPC)仪式运行:
两个阶段的输出都是包含最终的 α、β、γ 和 δ 点的验证密钥。一个关键的不变量是:γ 和 δ 必须是不同且独立的群元素。如果它们相等,则证明系统的健全性将完全崩溃。
让我们通过 Circom 文档 中的一个例子,看看我们如何使用 snarkjs 部署一个电路。
首先,让我们创建我们的电路:
pragma circom 2.0.0;
template Multiplier2 () {
signal input a;
signal input b;
signal output c;
c <== a * b;
}
component main = Multiplier2();
这是一个简单的电路,它证明我们知道数字 $c$ 的两个因子。例如,对于 $c=4$,我们提供一个证明,表明我们知道两个因子,例如 $a=b=2$。这是一个相当简单的例子,但它符合我们的目的。
下一步是编译电路:
circom multiplier2.circom --r1cs --wasm --sym
之后,我们通过初始化一个新的 Powers of Tau 仪式来启动第一阶段:
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
我们为仪式做出贡献:
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau \
--name="**第一次贡献**" -v
现在我们已经在 pot12_0001.ptau 中有了贡献,可以进行第二阶段。正如我们所提到的,第二阶段是针对特定电路的。所以我们首先准备它:
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
接下来,我们生成一个 .zkey 文件,其中将包含证明密钥和验证密钥以及所有第二阶段的贡献。我们为我们的电路启动一个新的 zkey:
snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey
我们为第二阶段做出贡献:
snarkjs zkey contribute multiplier2_0000.zkey multiplier2_0001.zkey \
--name="**第一位贡献者姓名**" -v
最后,我们导出验证密钥:
snarkjs zkey export verificationkey multiplier2_0001.zkey verification_key.json
要生成证明,我们首先创建一个 witness:
echo '{"a": "2", "b": "2"}' > input.json
cd multiplier2_js
node generate_witness.js multiplier2.wasm ../input.json ../witness.wtns
cd ..
然后创建并验证证明:
snarkjs groth16 prove multiplier2_0001.zkey witness.wtns proof.json public.json
snarkjs groth16 verify verification_key.json public.json proof.json
## [INFO] snarkJS: OK!
我们刚刚完成了 Circom 的快速入门。那么,在实际的漏洞利用中,到底出了什么问题?他们是否有一个复杂、经过高度优化的电路,其中包含一个微妙的约束不足的 bug(每个 ZK 工程师的噩梦),并被攻击者/白帽利用了?不是的。他们根本没有为设置仪式的第二阶段做任何贡献。 那么在这种情况下,底层发生了什么?
在 src/zkey_new.js -- snarkjs 中,当创建一个新的 zkey 时,snarkjs 会将 γ₂ 和 δ₂ 都初始化为相同的值,即 G2 生成点:
const bg2 = new Uint8Array(sG2);
curve.G2.toRprLEM(bg2, 0, curve.G2.g);
await fdZKey.write(bg2); // gamma2
await fdZKey.write(bg1); // delta1
await fdZKey.write(bg2); // delta2
这是有意为之的,因为它是一个占位符。用户应该运行第二阶段贡献(snarkjs zkey contribute),这会使 δ 随机化,同时保持 γ 不变。
在 src/zkey_contribute.js 中,贡献会将 δ 乘以一个随机标量:
zkey.vk_delta_1 = curve.G1.timesFr(zkey.vk_delta_1, curContribution.delta.prvKey);
zkey.vk_delta_2 = curve.G2.timesFr(zkey.vk_delta_2, curContribution.delta.prvKey);
经过适当的第二阶段贡献后,δ₂ 变为 $[random_key] \cdot G_2$,使其与 γ₂ 不同。但如果从未应用任何贡献,两者都保持为 G2 生成点。这正是被利用的漏洞。
我们可以通过运行 不带 贡献步骤的设置来验证这一点:
circom multiplier2.circom --r1cs --wasm --sym --c && \
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v && \
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau \
--name="First contribution" -v && \
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v && \
snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey && \
snarkjs zkey export verificationkey multiplier2_0000.zkey verification_key.json && \
jq '.vk_gamma_2' verification_key.json && \
jq '.vk_delta_2' verification_key.json
vk_gamma_2 和 vk_delta_2 都打印相同的值,即 BN254 G2 生成点:
[\
["10857046999023057135944570762232829481370756359578518086990519993285655852781",\
"11559732032986387107991004021392285783925812861821192530917403151452391805634"],\
["8495653923123431417604973247489272438418190587263600148770280649306958101930",\
"4082367875863433681332203403145435568316851327593401208105741076214120093531"],\
["1", "0"]\
]
在任何人可以证明或验证之前,可信设置仪式会生成一个 证明密钥(用于生成证明)和一个 验证密钥(用于检查证明)。验证密钥包含以下参数:
| 参数 | 群 | 角色 |
|---|---|---|
| α | G1 | 来自设置的固定元素 |
| β | G2 | 来自设置的固定元素 |
| γ | G2 | 随机化公共输入项 |
| δ | G2 | 随机化证明元素 C |
| $IC[0..n]$ | G1 | 编码电路的公共输入 |
值得注意的是,γ 和 δ 必须是独立的随机元素。这就是仪式的第二阶段所产生的。
Groth16 证明由三个椭圆曲线点组成: $\pi=(A,B,C)$ 其中 $A,C \in G_1$ 和 $B \in G_2$
这些由证明者使用 witness 和证明密钥计算。
验证者使用椭圆曲线配对检查单个方程。配对 $e(P,Q)$ 接收一个 G1 点和一个 G2 点,并将它们映射到一个目标群 GT。关键属性是 双线性:
$$ \begin{aligned} e(aP,bQ) &= e(P,Q)^{ab} \ e(P+P',Q) &= e(P,Q) \cdot e(P',Q) \end{aligned} $$
验证方程是:
$$e(A,B)=e(\alpha,\beta) \cdot e(vk_x,\gamma) \cdot e(C,\delta)$$
或者等效地,通过取负将 $e(A,B)$ 移到右侧:
$$e(-A,B) \cdot e(\alpha,\beta) \cdot e(vk_x,\gamma) \cdot e(C,\delta)=1$$
其中 $vk_x$ 由公共输入计算:
$$vk_x=IC[0]+input[0] \cdot IC[1]+input[1] \cdot IC[2]+\cdots$$
在我们的 Multiplier2 示例中,只有一个公共输入 ($c$),所以:
$$vk_x=IC[0]+c \cdot IC[1]$$
所以,我们有:
在没有 witness 的情况下,找到满足此方程的 A、B、C 在计算上是不可行的,只要 α、β、γ、δ 是具有未知离散对数的独立随机元素。
由于设置被跳过,我们有 $\gamma=\delta=G_2$(生成点)。这给了攻击者两次抵消。
步骤 1:抵消公共输入和证明项。
因为 $\gamma=\delta$,方程的最后两项共享相同的 G2 点:
$$e(vk_x,\gamma) \cdot e(C,\delta)=e(vk_x,\gamma) \cdot e(C,\gamma)=e(vk_x+C,\gamma)$$
如果我们选择 $C=-vk_x$(对 y 坐标取负):
$$e(vk_x+(-vk_x),\gamma)=e(O,\gamma)=1$$
无穷远点 O 抵消了配对。两个项都消失了。
步骤 2:抵消设置项。
我们仍然需要 $e(-A,B) \cdot e(\alpha,\beta)=1$。由于 α 和 β 是公开的(可以从验证密钥中读取),我们设置:
$A=\alpha, B=\beta$
那么:
$$e(-\alpha,\beta) \cdot e(\alpha,\beta)=e(-\alpha+\alpha,\beta)=e(O,\beta)=1$$
步骤 3:完整方程崩溃。
$$e(-A,B) \cdot e(\alpha,\beta) \cdot e(vk_x,\gamma) \cdot e(C,\delta)=1 \cdot 1=1$$
验证通过。我们根本不需要 witness。
为了伪造 $c=999$ 的证明(在不知道任何 $a, b$ 使得 $a \cdot b=999$ 的情况下):
使用椭圆曲线标量乘法和 G1 上的加法计算 $vk_x=IC[0]+999 \cdot IC[1]$。
设置 $A=\alpha$ 和 $B=\beta$(直接从验证密钥中复制)。
设置 $C=-vk_x=(vk_x.x, p-vk_x.y)$,其中 $p$ 是 BN254 域素数。
我们编写了一个小型的 Python 脚本 用于伪造证明。运行它:
$ python3 forge_proof.py
Forged proof for c = 999
A = alpha from VK
B = beta from VK
C = -vk_x = (866389343102574678537910566160387043799892038892793114915893462415946246044\
5, 8309882939490366207347146925892408415726053504120286978219270336676306681419)
Written: forged_proof.json, forged_public.json
然后验证伪造的证明:
$ snarkjs groth16 verify verification_key.json forged_public.json forged_proof.json
[INFO] snarkJS: OK!
验证者欣然接受“有人知道 $a, b$ 使得 $a \cdot b=999$”,但没有人证明过这样的事情。
这种确切的漏洞,即 $\gamma=\delta=G_2$,已在野外被利用,针对两个已部署的协议,两者都使用 Circom/snarkjs 且跳过了第二阶段仪式。
Foom 是 Base 和以太坊主网上的一个彩票/赌博 dApp,它使用 Groth16 证明通过 collect() 函数进行提款。验证密钥的 $\delta$ 和 $\gamma$ 都被设置为 BN254 G2 生成点,允许任何人伪造任意公共输入的有效证明。
由 @duha_real 在 Base 上领导的白帽救援行动,以及由 whitehat-rescue.eth 在以太坊上独立进行的行动,在恶意行为者利用之前抽干了合约。利用合约从链上验证器读取 $\alpha, \beta$ 和 $IC[0..6]$,为每次迭代计算递增的 nullifier 的 $vk_x$,设置 $C=-vk_x$,并循环调用 collect()。在 Base 上,10 次迭代抽干了 99.97% 的代币;在以太坊上,30 次迭代抽干了 99.99%。
| 链 | 抽干金额 | 抽干百分比 |
|---|---|---|
| Base | 约 $4.588 \times 10^{30}$ 代币 | 99.97% |
| ETH Mainnet | 约 $1.969 \times 10^{31}$ 代币 | 99.99% |
Veil 是 Base 上的一个隐私池,分叉自 Tornado Cash,用户存入固定面额的 0.1 ETH,并通过生成有效存款的 Groth16 证明进行提款。同样是相同的根本原因:验证器的 $\delta₂$ 和 $\gamma₂$ 都被设置为 G2 生成点。
攻击者在一个交易中抽干了整个池子:他们部署了一个合约,循环 29 次,每次都从公共输入计算 $vk_x$(使用伪造的 nullifier hash 0xdead0000 到 0xdead001c),设置 $C=-vk_x$,并调用 withdraw()。每次调用提取 0.1 ETH,总计 2.9 ETH,这是池子的全部余额。
| 字段 | 值 |
|---|---|
| 链 | Base |
| 提款次数 | 29 |
| 抽干的 ETH | 2.9 ETH |
| 使用的 Nullifiers | 0xdead0000 到 0xdead001c |
当我们审视这个漏洞时,第一个想法是这可能发生在我们审计过的项目中,因为很多时候部署不在审计范围内,并且通常不会与我们共享。这是我们即将改变的地方。我们将始终坚持审查部署代码和脚本。此外,我们建议任何团队在部署之前,都让专家检查其代码库的每个部分。
最后,一旦我们弄清楚了这个问题的影响以及可能发生的更多漏洞利用,我们联系了我们在 Dedaub 的好朋友(特别感谢 Yannis Smaragdakis),他们使用其 高级工具 在许多 EVM 链(那些在 app.dedaub.com 上有完整支持的链)上进行了全面扫描,以识别存储相同 G2 元素两次的合约。尽管我们识别出了一些合约(许多与此问题无关),但没有任何相关合约(Groth16 验证器中 $\delta = \gamma$)有近期显著活动或锁定价值。此外,我们还在 GitHub 中搜索了具有该模式的仓库,发现了一些。一个例子是出于教育目的的 TC 重建,获得了 248 颗星。无论如何:请检查你的验证密钥。
作为我们对此事件的回应,我们还在 zkao 中添加了对此类漏洞的检测。zkao 是我们针对 Circom 电路的 AI 驱动的持续安全扫描器。zkao 运行经过 100 多个真实 ZK 审计训练的多智能体分析,而缺少第二阶段贡献正是那种受益于自动化、持续检查而非一次性审查的部署级别问题。了解更多关于 zkao 的信息。
zkSecurity 为包括零知识证明、MPCs、FHE 和共识协议在内的密码系统提供审计、研究和开发服务。
- 原文链接: blog.zksecurity.xyz/post...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!