Skip to content

Loccturno/zk-multi-layer-exploit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZK Multi-Layer Exploit — Foundry PoC

📋 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.

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 — 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

License

MIT

About

Three composable ZK bugs across circuit + Solidity integration: underconstrained commitment, dead Merkle public input, and unbound recipient. Foundry test suite + reference fixes.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors