找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3533

积分

0

好友

487

主题
发表于 17 小时前 | 查看: 2| 回复: 0

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

关于Veil Cash被攻击的推文截图

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

Blockaid发布的Foom Club攻击警报截图

那么,这里出了什么问题?这两个协议都使用了 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_2vk_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):

  1. 使用椭圆曲线标量乘法和 G₁ 上的加法计算 vk_x = 999 · vk_x_c。
  2. 设置 A = α 和 B = β(直接从验证密钥中复制)。
  3. 设置 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 哈希 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 验证器中 γ₂ = δ₂)都没有重要的近期活动或锁定价值。此外,我们在 GitHub 中查找了具有该模式的仓库,其中一些有。一个例子是出于教育目的的 TC 重建,它拥有 248 颗星。无论如何:请检查你的验证密钥

作为我们对此事件的回应,我们还将为此类漏洞添加检测功能到 zkao,这是我们为 Circom 电路提供的 AI 驱动的持续安全扫描器。zkao 运行基于 100 多个真实 ZK 审计训练的多代理分析,而缺失的阶段 2 贡献正是那种从自动化、持续检查而非一次性审查中受益的部署级别问题。了解更多关于 zkao 的信息

zkSecurity 为包括零知识证明、MPCs、FHE 和共识协议在内的密码学系统提供审计、研究和开发服务。

了解更多 →

  • 原文链接: blog.zksecurity.xyz/post...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

密码学安全是区块链应用的基石,此次针对 ZK 验证器的攻击突显了部署环节的风险往往被低估。希望开发者能从这些真实案例中吸取教训,更严谨地对待每一个部署步骤。如果你想了解更多关于逆向工程安全漏洞的深度分析,欢迎访问 云栈社区 的相关板块进行交流学习。




上一篇:Prompt、Agent、Skill、MCP、Claude Code概念辨析:AI协同工作全解析
下一篇:最新一周游戏资讯速览:《生化危机9》首发表现与多款大作动态
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-1 23:26 , Processed in 0.502717 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表