文章探讨了近期两起针对零知识证明Groth16电路的攻击事件。这些攻击均源于Groth16可信设置中第二阶段贡献的缺失,导致攻击者能够伪造证明。一个项目损失了5 ETH,另一个涉及约1.5M美元的资产被白帽黑客成功挽救,文章详细解释了原理并强调了部署代码审查的重要性。

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

昨天,duha_real(zkSecurity 团队成员)认识到这个问题,并接管了 The Foom Heist Challenge Bug Bounty,该赏金自 2025 年 6 月 27 日以来一直活跃(价值约 50 万美元)。几乎同时,另一位 白帽 黑客 在 Ethereum 上利用了 Foom 合约,以防止任何恶意攻击。你可以在 这里 找到 beacon302 提供的一个非常详细的 PoC,但这与之前原始 bug 的利用方式非常相似(请注意,一些网站将该问题报告为攻击,但事实并非如此)。

那么,这里出了什么问题?这两个协议都使用了 Circom 和 snarkjs,这可能是目前最常用的 SNARKs 框架组合,尤其是 Groth16 协议(至少从部署数量来看),它需要一个可信设置。剧透:在这两种情况下,设置阶段都出了大问题。
Groth16 证明依赖于一组公共参数,这些参数必须在任何人创建或验证证明之前生成。这个生成过程被称为 可信设置。 具体来说,设置会产生密码学参数,其中包括像 $\alpha$、$\beta$、$\gamma$ 和 $\delta$ 这样的特殊值。这些是源自秘密随机数(有时被称为“有毒废物”)的椭圆曲线点。整个证明系统的安全性取决于这些秘密是真正随机的,然后被永久销毁。如果任何人保留它们,他们就可以伪造证明。
为了降低这种风险,设置分为两个阶段,通常作为多方计算 (MPC) 仪式运行:
两个阶段的输出都是包含最终 $\alpha$、$\beta$、$\gamma$ 和 $\delta$ 点的验证密钥。一个关键的不变性:$\gamma$ 和 $\delta$ 必须是不同的、独立的群元素。如果它们相等,证明系统的健全性将完全崩溃。
让我们看一个来自 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 仪式来开始阶段 1:
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
我们为仪式做出贡献:
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau \
--name="First contribution" -v
现在我们已经在 pot12_0001.ptau 中有了贡献,可以继续进行阶段 2。正如我们提到的,阶段 2 是电路特有的。所以我们首先准备它:
snarkjs powersoftau prepare phase2 pot12_0001.ptau pot12_final.ptau -v
接下来,我们生成一个 .zkey 文件,该文件将包含证明密钥和验证密钥以及所有阶段 2 的贡献。我们为我们的电路启动一个新的 zkey:
snarkjs groth16 setup multiplier2.r1cs pot12_final.ptau multiplier2_0000.zkey
我们为第二阶段做出贡献:
snarkjs zkey contribute multiplier2_0000.zkey multiplier2_0001.zkey \
--name="1st Contributor 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 工程师最可怕的噩梦),并被攻击者/ 白帽 利用了?不。他们只是没有对设置仪式的第二阶段做出任何贡献。 那么在这种情况下,内部发生了什么?
在 snarkjs 的 src/zkey_new.js 中,当创建一个新的 zkey 时,snarkjs 将 $\gamma_2$ 和 $\delta_2$ 都初始化为相同的值,即 $G_2$ 生成点:
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
这是有意为之,因为它是一个占位符。用户应该运行阶段 2 的贡献 (snarkjs zkey contribute),这将随机化 $\delta$ 而不改变 $\gamma$。
在 src/zkey_contribute.js 中,贡献将 $\delta$ 乘以一个随机标量:
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);
经过适当的阶段 2 贡献后,$\delta_2$ 变为 $[\text{random_key}] \cdot G_2$,使其与 $\gamma_2$ 不同。但如果从未应用任何贡献,两者都保持为 $G_2$ 生成器。这正是被利用的漏洞。
我们可以通过运行 不带 贡献步骤的设置来亲自验证这一点:
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 $G_2$ 生成器:
[\
["10857046999023057135944570762232829481370756359578518086990519993285655852781",\
"11559732032986387107991004021392285783925812861821192530917403151452391805634"],\
["8495653923123431417604973247489272438418190587263600148770280649306958101930",\
"4082367875863433681332203403145435568316851327593401208105741076214120093531"],\
["1", "0"]\
]
在任何人可以证明或验证之前,可信设置仪式会生成一个 证明密钥(用于生成证明)和一个 验证密钥(用于检查证明)。验证密钥包含以下参数:
| 参数 | 群 | 作用 |
|---|---|---|
| $\alpha$ | $G_1$ | 来自设置的固定元素 |
| $\beta$ | $G_2$ | 来自设置的固定元素 |
| $\gamma$ | $G_2$ | 随机化公共输入项 |
| $\delta$ | $G_2$ | 随机化证明元素 C |
| $IC[0..n]$ | $G_1$ | 编码电路的公共输入 |
值得注意的是,$\gamma$ 和 $\delta$ 必须是独立的随机元素。这就是仪式的阶段 2 所产生的。
Groth16 证明由三个椭圆曲线点组成:
$\pi=(A,B,C)$ 其中 $A,C \in G_1$ 和 $B \in G_2$
这些由证明者使用 witness 和证明密钥计算。
验证者使用椭圆曲线配对检查一个方程。配对 $e(P,Q)$ 接受一个 $G_1$ 点和一个 $G_2$ 点,并将它们映射到目标群 $G_T$。关键属性是 双线性:
$$e(aP,bQ)=e(P,Q)^{ab}$$ $$e(P+P',Q)=e(P,Q) \cdot e(P',Q)$$
验证方程为:
$$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]+\text{input}[0] \cdot IC[1]+\text{input}[1] \cdot IC[2]+\cdots$$
在我们的 Multiplier2 例子中,有一个公共输入 ($c$),所以:
$$vk_x=IC[0]+c \cdot IC[1]$$
所以,我们有:
在没有 witness 的情况下,找到满足此方程的 $A, B, C$ 在计算上是不可行的,只要 $\alpha, \beta, \gamma, \delta$ 是具有未知离散对数的独立随机元素。
由于跳过了设置,我们有 $\gamma=\delta=G_2$(生成器)。这给了攻击者两个抵消。
步骤 1:抵消公共输入和证明项。
因为 $\gamma=\delta$,方程的最后两项共享相同的 $G_2$ 点:
$$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$。由于 $\alpha$ 和 $\beta$ 是公开的(可以从验证密钥中读取),我们设置:
$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$):
使用椭圆曲线标量乘法和 $G_1$ 上的加法计算 $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 且跳过了阶段 2 仪式。
Foom 是一个部署在 Base 和 Ethereum 主网上的彩票/博彩 dApp,它使用 Groth16 证明通过 collect() 函数进行提款。验证密钥的 $\delta$ 和 $\gamma$ 都被设置为 BN254 $G_2$ 生成器,允许任何人伪造任意公共输入的有效证明。
在恶意行为者能够利用之前,由 @duha_real 在 Base 上领导,并由 whitehat-rescue.eth 在 Ethereum 上独立进行的 白帽 救援行动,耗尽了合约。利用合约从链上验证器读取 $\alpha$、$\beta$ 和 $IC[0..6]$,为每个迭代计算一个递增的 nullifier 的 $vk_x$,设置 $C=-vk_x$,并循环调用 collect()。在 Base 上,10 次迭代耗尽了 99.97% 的代币;在 Ethereum 上,30 次迭代耗尽了 99.99%。
| 链 | 耗尽数量 | 耗尽百分比 |
|---|---|---|
| Base | $~4.588 \times 10^{30}$ 代币 | 99.97% |
| ETH 主网 | $~1.969 \times 10^{31}$ 代币 | 99.99% |
Veil 是 Base 上的一个隐私池,从 Tornado Cash 分叉而来,用户存入固定面额的 0.1 ETH,并通过生成有效存款的 Groth16 证明来提款。同样的根本原因适用:验证器的 $\delta_2$ 和 $\gamma_2$ 都被设置为 $G_2$ 生成器。
攻击者通过一笔交易耗尽了整个池子:他们部署了一个合约,循环 29 次,每次从公共输入(使用伪造的 nullifier 哈希 0xdead0000 到 0xdead001c)计算 $vk_x$,设置 $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,这是我们为 Circom 电路提供的 AI 驱动的持续安全扫描器。zkao 运行基于 100 多个真实 ZK 审计训练的多代理分析,而缺失的阶段 2 贡献正是那种从自动化、持续检查而非一次性审查中受益的部署级别问题。了解更多关于 zkao 的信息。
zkSecurity 为包括零知识证明、MPCs、FHE 和共识协议在内的密码学系统提供审计、研究和开发服务。
- 原文链接: blog.zksecurity.xyz/post...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!