Loccturno/zk-multi-layer-exploit
GitHub: Loccturno/zk-multi-layer-exploit
Stars: 0 | Forks: 0
# ZK Multi-Layer Exploit — Foundry PoC
A reproducible proof-of-concept showing how **three composable bugs**, spread
across the Circom circuit and the Solidity integration layer, let an attacker
drain a private vault, and how a front-runner can hijack legitimate proofs.
The takeaway is the one most easily missed in ZK audits: **a "valid" Groth16
proof proves only that the prover satisfied the constraints you wrote** —
not the constraints you meant. And even a perfect circuit can be undone by a
careless contract wrapper.
## The Three Bugs
### BUG #1 — Underconstrained commitment check (circuit)
component noteCommit = Poseidon(2);
noteCommit.inputs[0] <== secret;
noteCommit.inputs[1] <== noteBalance;
// BUG: missing `noteCommit.out === expectedCommit;`
The circuit computes the Poseidon hash of `(secret, noteBalance)` but never
asserts it equals the on-chain `expectedCommit`. The prover can claim to own
**any** commitment with **any** `(secret, noteBalance)` pair. There is no
binding between the user's actual deposit and the proof.
### BUG #2 — Dead public input (circuit)
signal input merkleRoot; // declared
signal input merkleSiblings[4]; // declared
// ... never referenced again
The circuit declares a `merkleRoot` public input and four `merkleSiblings`
private inputs but uses **none** of them. The compiler even tells you:
`private inputs: 6 (1 belong to witness)` — meaning 5 of the 6 declared
private inputs are never actually wired in. A correct circuit would compute
a Merkle path from `noteCommit` to `merkleRoot` using the siblings; this one
does not. Even if BUG #1 were fixed, the prover could forge notes that
were never deposited.
### BUG #3 — Proof not bound to recipient (contract)
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
}
A Groth16 proof is a pure cryptographic object. It has no notion of "for
whom". If the contract pays out to `msg.sender` without forcing the proof
to commit to that address, then any third party who sees a valid proof in
the mempool can copy it, submit first, and walk away with the funds.
## Why Three Bugs Together
Each bug is exploitable in isolation. The PoC combines them to show:
1. **BUGS #1 + #2** let the attacker fabricate a proof of withdrawal for
funds they never deposited.
2. **BUG #3** lets a front-runner hijack any other user's withdrawal —
even one made from a correct circuit.
The lesson for auditors: **circuit security and contract security are
inseparable**. A protocol that ships a perfect circuit and a careless
wrapper has the same end-state as one that ships a buggy circuit and a
careful wrapper: drained funds. You must audit both layers, in
combination.
## The Fixes
### Circuit (see `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
### Contract (see `src/SafeVault.sol`)
function withdraw(... uint[3] pubSignals, address recipient) external {
require(recipient == msg.sender, "recipient mismatch");
// ...
}
This contract-level fix is a partial mitigation. The **cleanest** fix is
to add `recipient` as a public input to the circuit itself, so that the
proof becomes cryptographically bound to a specific address at proof time.
Production ZK protocols (Tornado Cash, Aztec, zkSync) all do this.
## Reproduction
### Requirements
- Node 20+, npm
- Foundry (`forge`)
- Rust + Cargo
- Circom 2.x (compiled from source)
- `snarkjs` global install (`npm install -g snarkjs`)
### Build
npm install
# Compile the vulnerable circuit
cd circuits/vulnerable && circom VaultClaim.circom --r1cs --wasm --sym -o . && cd ../..
# Powers of Tau (reuse from any existing ZK project, or generate fresh)
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
### Generate Inputs and Proofs
# Generate honest + exploit input JSONs (computes a real Poseidon commitment for honest)
node scripts/generate_inputs.js
# Honest case (passes — circuit accepts because it does no real check)
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 case — same flow, but with fabricated commitment and garbage 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! (the exploit)
### Run the Foundry Tests
forge test -vv
Expected:
[PASS] test_Bug1And2_AttackerDrainsNaiveVault — forged proof drained vault
[PASS] test_Bug3_FrontRunnerStealsFromNaiveVault — front-runner stole payout
[PASS] test_SafeVault_RejectsFrontRunner — fix works
## What I Look For
Reviewing this kind of bug pattern, the scan I find useful has two passes.
**Circuit layer.** I trace each declared public input forward into the
constraints. If it never appears in one, it is functionally not there —
the compiler does not warn. Same for `component X = Y();` — if no
constraint involves `X.out`, the component is decorative.
**Integration layer.** I look at how the contract obtains each public
input the verifier expects, and what it binds them to. A `pubSignals[i]`
read from calldata with no check against `msg.sender`, `block.number`,
or a similar binding context means the proof is replayable.
The recurring pattern is the gap between what the circuit constrains and
what the contract assumes.
## What This Isn't
This is a minimal PoC, not a deployed-protocol exploit. Real systems built
on Circom — Tornado Cash, Semaphore, Aztec, zkSync circuits — include
recipient binding, nullifier sets, and real Merkle inclusion proofs. The
circuit here deliberately omits these to keep each bug visible in
isolation. The patterns shown are the patterns that have caused real-world
losses; the specific contract is the minimal scaffolding to make each bug visible.
## Related PoC
See also: [zk-underflow-exploit](https://github.com/Loccturno/zk-underflow-exploit)
— a companion PoC showing a single field-underflow bug in a balance-transfer
circuit, with an end-to-end on-chain proof of exploitation.
## References
- [snarkjs](https://github.com/iden3/snarkjs)
- [Circom 2 documentation](https://docs.circom.io/)
- [circomlib](https://github.com/iden3/circomlib)
- [circomlibjs](https://github.com/iden3/circomlibjs)
- [0xPARC ZK bug tracker](https://github.com/0xPARC/zk-bug-tracker)
## License
MIT