首次 ZK 攻击发生,且出乎意料

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

banner

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 的报告。

Veil Hack Tweet

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

Foom Tweet

那么,这里出了什么问题?这两个协议都使用了 Circom 和 snarkjs,这可能是目前最常用的 SNARKs 框架组合,尤其是 Groth16 协议(至少从部署数量来看),它需要一个可信设置。剧透:在这两种情况下,设置阶段都出了大问题。

什么是可信设置仪式?

Groth16 证明依赖于一组公共参数,这些参数必须在任何人创建或验证证明之前生成。这个生成过程被称为 可信设置。 具体来说,设置会产生密码学参数,其中包括像 $\alpha$、$\beta$、$\gamma$ 和 $\delta$ 这样的特殊值。这些是源自秘密随机数(有时被称为“有毒废物”)的椭圆曲线点。整个证明系统的安全性取决于这些秘密是真正随机的,然后被永久销毁。如果任何人保留它们,他们就可以伪造证明。

为了降低这种风险,设置分为两个阶段,通常作为多方计算 (MPC) 仪式运行:

  • 阶段 1 (Powers of Tau):生成初始参数。多个参与者依次贡献随机性。只要至少有一名参与者是诚实的并销毁了他们的秘密,输出就是安全的。这个阶段可以跨不同的电路重复使用。
  • 阶段 2 (Circuit-specific):接受阶段 1 的输出和一个特定电路,然后生成最终的证明密钥和验证密钥。同样,多个参与者贡献随机性。这个阶段对 $\delta$ 参数进行随机化。

两个阶段的输出都是包含最终 $\alpha$、$\beta$、$\gamma$ 和 $\delta$ 点的验证密钥。一个关键的不变性:$\gamma$ 和 $\delta$ 必须是不同的、独立的群元素。如果它们相等,证明系统的健全性将完全崩溃。

使用 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=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 将 $\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_2vk_delta_2 都打印相同的值,即 BN254 $G_2$ 生成器:

[\
  ["10857046999023057135944570762232829481370756359578518086990519993285655852781",\
   "11559732032986387107991004021392285783925812861821192530917403151452391805634"],\
  ["8495653923123431417604973247489272438418190587263600148770280649306958101930",\
   "4082367875863433681332203403145435568316851327593401208105741076214120093531"],\
  ["1", "0"]\
]

如何利用这个 bug

Groth16 验证

在任何人可以证明或验证之前,可信设置仪式会生成一个 证明密钥(用于生成证明)和一个 验证密钥(用于检查证明)。验证密钥包含以下参数:

参数 作用
$\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]$$

所以,我们有:

  • $e(\alpha,\beta)$ 是来自设置的固定常量。将证明绑定到这个特定电路。
  • $e(vk_x,\gamma)$ 将证明绑定到特定的公共输入。因为 $\gamma$ 具有未知的离散对数,你无法操作此项。
  • $e(C,\delta)$ 将证明元素 C 绑定到设置。因为 $\delta$ 具有未知的离散对数,你无法自由选择 C。
  • $e(A,B)$ 编码了证明者对 witness 的知识。

在没有 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。

为我们的 Multiplier2 伪造证明

要伪造 $c=999$ 的证明(不知道任何 $a, b$ 使得 $a \cdot b=999$):

  1. 使用椭圆曲线标量乘法和 $G_1$ 上的加法计算 $vk_x=IC[0]+999 \cdot IC[1]$。

  2. 设置 $A=\alpha$ 和 $B=\beta$(直接从验证密钥中复制)。

  3. 设置 $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 协议 (~140 万美元)

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 协议 (~$5 千美元)

Veil 是 Base 上的一个隐私池,从 Tornado Cash 分叉而来,用户存入固定面额的 0.1 ETH,并通过生成有效存款的 Groth16 证明来提款。同样的根本原因适用:验证器的 $\delta_2$ 和 $\gamma_2$ 都被设置为 $G_2$ 生成器。

攻击者通过一笔交易耗尽了整个池子:他们部署了一个合约,循环 29 次,每次从公共输入(使用伪造的 nullifier 哈希 0xdead00000xdead001c)计算 $vk_x$,设置 $C=-vk_x$,并调用 withdraw()。每次调用提取 0.1 ETH,总计 2.9 ETH,这是池子的全部余额。

字段
Base
提款次数 29
耗尽的 ETH 2.9 ETH
使用的 nullifiers 0xdead00000xdead001c

结论

当我们审视这个漏洞时,第一个想法是这可能发生在我们审计过的项目中,因为很多时候部署不属于范围之内,并且通常不与我们共享。这是我们即将改变的地方。我们将始终坚持审查部署代码和脚本。此外,我们建议任何团队在部署之前让专家审查其代码库的每个部分。

最后,一旦我们弄清楚了这个问题的影响以及可能发生的更多漏洞,我们联系了我们在 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 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
zksecurity
zksecurity
Security audits, development, and research for ZKP, FHE, and MPC applications, and more generally advanced cryptography.