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