📋 Security Review → — formal write-up of findings (2 Critical, 1 High)
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.
TL;DR:
- A circuit can compile, run, and accept proofs even when its core check is missing entirely.
- A public input that is declared but never constrained is functionally not there.
- A cryptographically valid proof carries no inherent recipient. If the contract does not bind it to
msg.sender, anyone can replay it.
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.
signal input merkleRoot; // declared
signal input merkleSiblings[4]; // declared
// ... never referenced againThe 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.
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.
Each bug is exploitable in isolation. The PoC combines them to show:
- BUGS #1 + #2 let the attacker fabricate a proof of withdrawal for funds they never deposited.
- 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.
// 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 merkleRootfunction 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.
- Node 20+, npm
- Foundry (
forge) - Rust + Cargo
- Circom 2.x (compiled from source)
snarkjsglobal install (npm install -g snarkjs)
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 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)forge test -vvExpected: [PASS] test_Bug1And2_AttackerDrainsNaiveVault — forged proof drained vault [PASS] test_Bug3_FrontRunnerStealsFromNaiveVault — front-runner stole payout [PASS] test_SafeVault_RejectsFrontRunner — fix works
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.
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.
See also: 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.
MIT