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 领域的人们非常注重安全性,并且该领域的研究人员更喜欢戴 白帽。此外,也许某个著名的团队只是还没有开始研究 ZK...
这把我们带到了今天,以及 上周日发布的这条推文。你也可以在 此链接 找到 beacon302 的报告。

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

那么,这里出了什么问题?这两个协议都使用了 Circom 和 snarkjs,这可能是目前最常用的 SNARKs 框架组合,尤其是 Groth16 协议(至少从部署数量来看),它需要一个可信设置。剧透:在这两种情况下,设置阶段都出了大问题。
什么是可信设置仪式?
Groth16 证明依赖于一组公共参数,这些参数必须在任何人创建或验证证明之前生成。这个生成过程被称为 可信设置。
具体来说,设置会产生密码学参数,其中包括像 α、β、γ 和 δ 这样的特殊值。这些是源自秘密随机数(有时被称为“有毒废物”)的椭圆曲线点。整个证明系统的安全性取决于这些秘密是真正随机的,然后被永久销毁。如果任何人保留它们,他们就可以伪造证明。
为了降低这种风险,设置分为两个阶段,通常作为多方计算 (MPC) 仪式运行:
- 阶段 1 (Powers of Tau):生成初始参数。多个参与者依次贡献随机性。只要至少有一名参与者是诚实的并销毁了他们的秘密,输出就是安全的。这个阶段可以跨不同的电路重复使用。
- 阶段 2 (Circuit-specific):接受阶段 1 的输出和一个特定电路,然后生成最终的证明密钥和验证密钥。同样,多个参与者贡献随机性。这个阶段对 δ 参数进行随机化。
两个阶段的输出都是包含最终 α、β、γ 和 δ 点的验证密钥。一个关键的不变性:γ 和 δ 必须是不同的、独立的群元素。如果它们相等,证明系统的健全性将完全崩溃。
使用 snarkjs 设置电路
让我们看一个来自 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=2, 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 工程师最可怕的噩梦),并被攻击者/ 白帽 利用了?不。他们只是没有对设置仪式的第二阶段做出任何贡献。 那么在这种情况下,内部发生了什么?
跳过阶段 2 时会发生什么
在 snarkjs 的 src/zkey_new.js 中,当创建一个新的 zkey 时,snarkjs 将 γ₂ 和 δ₂ 都初始化为相同的值,即 G₂ 生成点:
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),这将随机化 δ 而不改变 γ。
在 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);
经过适当的阶段 2 贡献后,δ₂ 变为 δ·G₂,使其与 γ₂ 不同。但如果从未应用任何贡献,两者都保持为 G₂ 生成器。这正是被利用的漏洞。
我们可以通过运行 不带 贡献步骤的设置来亲自验证这一点:
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₂ 生成器:
[\
["10857046999023057135944570762232829481370756359578518086990519993285655852781",\
"11559732032986387107991004021392285783925812861821192530917403151452391805634"],\
["8495653923123431417604973247489272438418190587263600148770280649306958101930",\
"4082367875863433681332203403145435568316851327593401208105741076214120093531"],\
["1", "0"]\
]
如何利用这个 bug
Groth16 验证
在任何人可以证明或验证之前,可信设置仪式会生成一个 证明密钥(用于生成证明)和一个 验证密钥(用于检查证明)。验证密钥包含以下参数:
| 参数 |
群 |
作用 |
| α, β |
G₁, G₂ |
来自设置的固定元素 |
| γ₂ |
G₂ |
来自设置的固定元素 |
| δ₂ |
G₂ |
来自设置的固定元素 |
| vk_x |
G₁ |
编码电路的公共输入 |
值得注意的是,γ₂ 和 δ₂ 必须是独立的随机元素。这就是仪式的阶段 2 所产生的。
Groth16 证明由三个椭圆曲线点组成:
A = α + r·δ + ... , B = β + s·δ + ... , C = (vk_x + h·δ + ...) / δ
其中 r, s, h 来自 witness
这些由证明者使用 witness 和证明密钥计算。
验证者使用椭圆曲线配对检查一个方程。配对 e 接受一个 G₁ 点和一个 G₂ 点,并将它们映射到目标群 G_T。关键属性是 双线性:
e(a·P, b·Q) = e(P, Q)^{a·b}
验证方程为:
e(A, B) = e(α, β) · e(vk_x, γ₂) · e(C, δ₂)
或者等效地,通过取反将 e(C, δ₂) 移到右侧:
e(A, B) · e(-C, δ₂) = e(α, β) · e(vk_x, γ₂)
其中 vk_x 由公共输入计算:
vk_x = Σ (public_input_i · vk_x_i)
在我们的 Multiplier2 例子中,有一个公共输入 (c),所以:
vk_x = c · vk_x_c
所以,我们有:
- e(α, β) 是来自设置的固定常量。将证明绑定到这个特定电路。
- e(vk_x, γ₂) 将证明绑定到特定的公共输入。因为 γ₂ 具有未知的离散对数,你无法操作此项。
- e(C, δ₂) 将证明元素 C 绑定到设置。因为 δ₂ 具有未知的离散对数,你无法自由选择 C。
- e(A, B) 编码了证明者对 witness 的知识。
在没有 witness 的情况下,找到满足此方程的 A, B, C 在计算上是不可行的,只要 γ₂ 和 δ₂ 是具有未知离散对数的独立随机元素。
由于跳过了设置,我们有 γ₂ = δ₂ = G₂(生成器)。这给了攻击者两个抵消。
步骤 1:抵消公共输入和证明项。
因为 γ₂ = δ₂,方程的最后两项共享相同的 G₂ 点:
e(vk_x, γ₂) · e(C, δ₂) = e(vk_x, G₂) · e(C, G₂) = e(vk_x + C, G₂)
如果我们选择 C = -vk_x(将 y 坐标取反):
e(vk_x + (-vk_x), G₂) = e(O, G₂) = 1
无穷远点 O 使配对失效。两项都消失了。
步骤 2:抵消设置项。
我们仍然需要 e(A, B) = e(α, β)。由于 α 和 β 是公开的(可以从验证密钥中读取),我们设置:
A = α, B = β
然后:
e(A, B) = e(α, β)
步骤 3:整个方程崩溃。
验证通过。我们根本不需要 witness。
为我们的 Multiplier2 伪造证明
要伪造 c = 999 的证明(不知道任何 a, b 使得 a * b = 999):
- 使用椭圆曲线标量乘法和 G₁ 上的加法计算 vk_x = 999 · vk_x_c。
- 设置 A = α 和 B = β(直接从验证密钥中复制)。
- 设置 C = -vk_x,其中 - 是 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 * b = 999”,但没有人证明过这样的事情。
真实世界的漏洞利用
这种确切的漏洞,即 γ₂ = δ₂,已在实际中被利用,针对两个已部署的协议,两者都使用 Circom/snarkjs 且跳过了阶段 2 仪式。
Foom 协议 (~140 万美元)
Foom 是一个部署在 Base 和 Ethereum 主网上的彩票/博彩 dApp,它使用 Groth16 证明通过 collect() 函数进行提款。验证密钥的 γ₂ 和 δ₂ 都被设置为 BN254 G₂ 生成器,允许任何人伪造任意公共输入的有效证明。
在恶意行为者能够利用之前,由 @duha_real 在 Base 上领导,并由 whitehat-rescue.eth 在 Ethereum 上独立进行的 白帽 救援行动,耗尽了合约。利用合约从链上验证器读取 α、β 和 vk_x,为每个迭代计算一个递增的 nullifier 的 vk_x,设置 C = -vk_x,并循环调用 collect()。在 Base 上,10 次迭代耗尽了 99.97% 的代币;在 Ethereum 上,30 次迭代耗尽了 99.99%。
| 链 |
耗尽数量 |
耗尽百分比 |
| Base |
16,877,325 代币 |
99.97% |
| ETH 主网 |
99,998,663 代币 |
99.99% |
Veil 协议 (~5 千美元)
Veil 是 Base 上的一个隐私池,从 Tornado Cash 分叉而来,用户存入固定面额的 0.1 ETH,并通过生成有效存款的 Groth16 证明来提款。同样的根本原因适用:验证器的 γ₂ 和 δ₂ 都被设置为 G₂ 生成器。
攻击者通过一笔交易耗尽了整个池子:他们部署了一个合约,循环 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 验证器中 γ₂ = δ₂)都没有重要的近期活动或锁定价值。此外,我们在 GitHub 中查找了具有该模式的仓库,其中一些有。一个例子是出于教育目的的 TC 重建,它拥有 248 颗星。无论如何:请检查你的验证密钥。
作为我们对此事件的回应,我们还将为此类漏洞添加检测功能到 zkao,这是我们为 Circom 电路提供的 AI 驱动的持续安全扫描器。zkao 运行基于 100 多个真实 ZK 审计训练的多代理分析,而缺失的阶段 2 贡献正是那种从自动化、持续检查而非一次性审查中受益的部署级别问题。了解更多关于 zkao 的信息。
zkSecurity 为包括零知识证明、MPCs、FHE 和共识协议在内的密码学系统提供审计、研究和开发服务。
了解更多 →
密码学和安全是区块链应用的基石,此次针对 ZK 验证器的攻击突显了部署环节的风险往往被低估。希望开发者能从这些真实案例中吸取教训,更严谨地对待每一个部署步骤。如果你想了解更多关于逆向工程和安全漏洞的深度分析,欢迎访问 云栈社区 的相关板块进行交流学习。