Veil Cash Groth16 delta == gamma setup collapse / forged withdrawals (2026-02-21)

  • Name: Veil Cash Groth16 delta == gamma setup collapse / forged withdrawals
  • Date: 2026-02-21
  • Protocol: Veil Cash
  • Chain: Base
  • Category: verification-key independence collapse / unfinalized Groth16 setup / forged proof withdrawals
  • Loss: 2.9 ETH drained from the pool, roughly $5k at the time
  • Summary: Veil Cash deployed a Tornado-style privacy pool on Base behind a Groth16 verifier whose verification key never preserved the independence assumptions the proof system needed. The deployed verifier’s gamma2 and delta2 values were identical and equal to the BN254 G2 generator, which is the exact placeholder state left behind by an unfinished or improperly finalized setup. That made proof verification collapse into a tautology: the attacker could choose A = alpha, B = beta, compute vk_x from attacker-chosen public inputs, set C = -vk_x, and make verifyProof() return true without knowing any deposit secret or valid witness. In one transaction, the attacker looped 29 times, used nullifiers 0xdead0000 through 0xdead001c, kept relayer, fee, and refund at zero so those IC[] terms vanished, and withdrew the pool’s full 2.9 ETH balance in 0.1 ETH chunks.
  • Trigger / entry point:
    • The exploit entry point was Veil Cash’s Tornado-style withdraw() path on privacy pool 0xD3560eF60Dd06E27b699372c3da1b741c80B7D90 on Base.
    • That pool trusted Groth16 verifier 0x1E65C075989189E607ddaFA30fa1a0001c376cfd as direct authority for withdrawals.
    • The verifier key embedded in that contract had gamma2 == delta2, so proof success no longer meant the caller knows a valid deposit witness.
  • Root cause:
    • The Solidity verifier logic was not the core problem; the deployed verification key itself was unsound.
    • Veil’s verification key left gamma2 and delta2 equal to the BN254 G2 generator instead of distinct ceremony-derived points.
    • Public analysis tied that shape to the canonical snarkjs Groth16 Phase-2 omission failure mode: exporting verifier material from the initial pre-contribution zkey state leaves gamma2 and delta2 parked on the same placeholder generator point.
    • In Groth16, those parameters are supposed to be independent. Once they collapse to the same public point, an attacker can algebraically cancel the public-input term with a forged C = -vk_x and cancel the alpha / beta term with A = alpha, B = beta.
    • The application then honored verifier success as full withdrawal authority, so cryptographic setup failure became direct reserve loss.
  • Exploit path:
    1. The attacker targeted Veil Cash’s 0.1 ETH privacy pool on Base and inspected the deployed verifier constants.
    2. They observed that gamma2 and delta2 were identical and equal to the BN254 G2 generator, meaning the verifier key had been deployed in a degenerate state.
    3. In transaction 0x5ff6dbc33e77fab8dc086bb9ea3c88f1ba81df198d24ec9fc0c5b50fb1a4a17d at Base block 42,410,815, the attacker used contract 0x5F68aD46F500949FA7E94971441F279A85cB3354 to automate the drain.
    4. The attack contract read the pool’s latest known Merkle root and denomination, chose recipient 0x49A7CA88094B59b15EaA28C8c6d9BFAb78d5F903, and iterated through fake nullifiers 0xdead0000 through 0xdead001c.
    5. For each forged withdrawal, it kept relayer = address(0), fee = 0, and refund = 0, so the corresponding IC[] terms vanished, then computed vk_x from the remaining public inputs and the verifier’s hardcoded IC[] points.
    6. It then set proof elements to the public-key-derived forgery: A = alpha1, B = beta2, and C = -vk_x.
    7. Because delta2 == gamma2, the pairing equation reduced to a tautology, so verifyProof() returned true even though no valid witness existed.
    8. The pool paid 0.1 ETH to the attacker-controlled recipient each time.
    9. Repeating that flow 29 times drained the full 2.9 ETH pool balance in a single transaction, leaving the pool at 0 and the attacker recipient up 2.9 ETH in the reproduced PoC path.
  • What the attacker needed to be true:
    • The deployed verifier key had to preserve the exact gamma2 == delta2 degeneracy that destroys Groth16 soundness.
    • The pool had to trust verifier success directly for withdrawal authorization.
    • The contract had to accept attacker-chosen public inputs as long as they met superficial checks like isKnownRoot(root) and nullifier freshness.
    • The attacker needed to compute vk_x from public inputs and the onchain IC[] constants, which the verifier made fully public.
    • The pool needed enough balance to make repeated forged withdrawals worthwhile.
  • Technical anatomy worth remembering:
    • This was not a subtle circuit bug. It was a verifier-key failure: the proof system’s root setup assumptions were already broken before witness logic mattered.
    • The exploit is memorable because the forged proof used only public verification-key material: A = alpha, B = beta, C = -vk_x.
    • The public-input shape matters: with relayer, fee, and refund all zero, the attacker only had to make the live root, fake nullifier, and recipient terms line up inside vk_x.
    • The fake nullifiers were intentionally obvious (0xdead0000, 0xdead0001, … 0xdead001c), which makes the incident a crisp example of proof verified no longer meaning real private state existed.
    • Veil’s use of a Tornado-style fixed-denomination pool meant every successful forged proof immediately became real ETH payout authority.
    • The published PoC reproduced the end state with pool balance 2.9 ETH -> 0, attacker balance 0 -> 2.9 ETH, and about 8,314,445 gas used across the draining transaction path.

Three proof-authority promotions this incident collapsed

  1. A verifier artifact existed at all

    • Veil had a deployed Groth16 verifier contract with hardcoded alpha, beta, gamma2, delta2, and IC[] points, so at a glance the pool looked like it had a normal proof gate in front of withdrawals.
    • That first fact only established there is a verifier-shaped object onchain, not the object came from a finalized safe ceremony.
  2. That artifact deserved to count as canonical withdrawal-proof truth for this exact pool

    • Veil silently promoted the malformed verifier into statement truth even though gamma2 == delta2 == G2 generator meant the verification key never preserved Groth16’s independence assumptions.
    • Once that second promotion failed, verifyProof() no longer meant the caller knows a valid Veil deposit witness for this root / nullifier / recipient tuple; it only meant the attacker found public inputs and public key material that satisfy a broken pairing equation.
  3. That accepted proof truth deserved full ETH release authority

    • The pool then gave the accepted verifier result direct fixed-denomination withdrawal power with no stronger backing or provenance boundary behind it.
    • That is why a setup-artifact mistake turned immediately into reserve loss: a malformed proof gate was still allowed to cash out 0.1 ETH repeatedly until the entire 2.9 ETH pool was empty.
  • Representative transactions and addresses:
    • Exploit tx: 0x5ff6dbc33e77fab8dc086bb9ea3c88f1ba81df198d24ec9fc0c5b50fb1a4a17d
    • Exploit block: Base 42,410,815
    • Vulnerable pool: 0xD3560eF60Dd06E27b699372c3da1b741c80B7D90
    • Vulnerable verifier: 0x1E65C075989189E607ddaFA30fa1a0001c376cfd
    • Attacker contract: 0x5F68aD46F500949FA7E94971441F279A85cB3354
    • Attacker recipient: 0x49A7CA88094B59b15EaA28C8c6d9BFAb78d5F903
  • Response / mitigation:
    • Public reporting centered on the broken verifier key and released a working proof-of-concept showing the full drain.
    • No recovery or whitehat rescue was reported for the 2.9 ETH already withdrawn.
    • The durable lesson was not patch the circuit but rebuild and redeploy from a completed trusted-setup pipeline, then verify the deployed key matches finalized artifacts.
    • In snarkjs terms, that means not exporting or deploying verifier constants from a zero-contribution *_0000.zkey artifact and proving that a real zkey contribute / finalization step happened before release.
    • zkSecurity’s follow-up matters operationally too: after understanding the exploit, they asked Dedaub to scan EVM chains for contracts that stored the same G2 element twice, using the exact gamma == delta fingerprint as a fleet-level detection heuristic rather than treating Veil as a one-off privacy-pool bug.
    • The healthier response object was not review this one compromised pool, but open one family-level quarantine sheet for every verifier, contract, chain, and reachable balance carrying the same malformed-key fingerprint, then work that sheet until each sibling deployment is rescued, drained, or proven clean.
    • Their reported result was that no other relevant live gamma == delta Groth16 verifiers with significant recent activity or value were found in that broader scan, but the response pattern is the lasting lesson — once one funded verifier shows a quarantined artifact-family fingerprint, sibling deployments and adjacent repos should be swept immediately.

Pattern tags

Why the exploit worked

  • Veil treated verifyProof() == true as direct ETH-withdrawal authority, so once the verifier key was unsound there was no second custody or provenance boundary left to stop payout.
  • The malformed key let the attacker satisfy the verifier with public key material alone: A = alpha, B = beta, and C = -vk_x for attacker-chosen public inputs.
  • Veil’s public-input checks were still easy to satisfy with fake-but-acceptable values: a known live root, fresh nullifiers, a recipient the attacker controlled, and zeroed relayer / fee / refund fields that simplified vk_x construction.
  • Release engineering and runtime inventory both failed closed in the same direction: the protocol funded a verifier family that still carried the recognizable zero-contribution gamma == delta fingerprint, and no family-level quarantine sweep started until after loss.

Detection and audit pivots

  • Re-audit the deployed verifier constants themselves, not only the circuit or generated Solidity template: for Groth16, explicitly compare gamma2, delta2, and known placeholder G2 generator values onchain.
  • Try the public-key-only forgery against the exact live contract surface: can a reviewer choose arbitrary accepted public inputs, compute vk_x from onchain IC[], and make verifyProof() pass without any witness?
  • Inventory every funded contract and residual exit path that still trusts the same verifier or exported-key lineage, then group them into one artifact-family response object instead of reviewing one pool at a time.
  • Treat one malformed-key fingerprint as a hunting rule, not a postmortem detail: if one verifier stores the same G2 element twice, sweep sibling deployments, repos, CI artifacts, and copied verifier constants immediately.

Failure-prevention checklist

  • Treat Groth16 setup completion as a hard deploy gate: a funded privacy pool should not launch unless the team can show the finalized zkey, the exported verifier key, and the exact deployed constants all reconcile automatically.
  • Refuse deployments where supposedly independent ceremony outputs are equal, generator-valued, or match known zero-contribution artifact fingerprints like gamma == delta == G2 generator.
  • Add deployment-time and post-deployment sanity checks that read the live verifier constants onchain and compare them against the finalized ceremony output instead of trusting local build directories or copied verification_key.json files.
  • Run adversarial property tests against the exact withdrawal surface, not only the circuit: choose accepted roots, fresh nullifiers, attacker-controlled recipients, and zeroed relayer / fee / refund fields, then prove that no public-key-only construction can satisfy verifyProof().
  • Keep a machine-readable verifier-family inventory so one malformed-key fingerprint immediately resolves to every sibling verifier, pool, chain, and reachable balance that shares the same artifact lineage.
  • Bound balances behind new proof-authorized withdrawal systems until the deployed verifier survives independent artifact review and the family sweep proves no copied malformed verifier remains live elsewhere.
  • Preserve pause, rescue, or migration rails while a new verifier family is still fresh in production; the pool should not rely on proof verified as the only remaining custody boundary during rollout.
  • Require release tooling to preserve the exact ceremony and export commands or hashes used to generate the verifier so responders can prove provenance instead of reconstructing it mid-incident.

Audit prompts

  • What exact finalized ceremony artifact produced this verifier, and where can a reviewer independently recompute the exported key hash and deployed constants?
  • Do any supposedly independent verification-key elements equal each other, equal known placeholder generator values, or otherwise look implausibly structured for a completed ceremony?
  • If a reviewer chooses a live root, a fresh nullifier, an attacker-controlled recipient, and zeroed relayer / fee / refund fields, what exact proof-system property still makes withdrawal impossible without a real note witness?
  • Which contracts, chains, or residual exit paths still trust this verifier family today, and where is that grouped blast-radius view maintained?
  • Do CI and deployment scripts explicitly reject zero-contribution zkey artifacts, copied placeholder verifier JSON, or other known malformed-key fingerprints before contracts can be funded?
  • What evidence shows the deployed verifier constants match the finalized export exactly, rather than merely coming from the same repo or generation script in principle?
  • If one verifier in this artifact family fingerprints as malformed after launch, who can pause, rescue, or drain each sibling deployment first, and what reachable balances are in priority order?
  • Are reviewers signing off only on circuit source and Solidity templates, or on the exact onchain verifier constants that actually hold withdrawal authority over live reserves?

Lessons that should already be standard

  • The verification key is custody-critical release material, not just build output. Auditing circuit source while hand-waving deployed verifier constants leaves the actual withdrawal authority unreviewed.
  • A standard Groth16 verifier template is not a defense. If the constants come from a half-finished setup, the application has deployed a standardized reserve-drain primitive.
  • Privacy pools are still custody systems. The moment verifyProof() directly authorizes ETH release, ceremony hygiene and artifact lineage become part of the live withdrawal boundary.
  • One recognizable malformed-key fingerprint is already a fleet incident, not a local bug. The useful response object is one grouped sweep over sibling verifiers, copied repos, and reachable balances, not one pool-specific postmortem.
  • Artifact provenance should be easier to verify than to narrate. If the team cannot quickly prove where the verifier key came from, that uncertainty already belongs in the solvency and incident-response model.
  • New proof-authorized payout systems need bounded rollout just like new bridges or custody code. Until the deployed verifier artifact survives independent scrutiny, balances should stay capped and rescue rails should stay alive.

Why this incident matters

  • It is a very clean live exploit where a zk application’s core failure was neither circuit underconstraint nor settlement mismatch, but a malformed deployed verification key.
  • The incident makes the release-engineering lesson painfully concrete: a standard verifier template is worthless if the key constants came from a half-finished setup.
  • It also shows how directly proof-authorized privacy systems can fail catastrophically once ceremony hygiene becomes part of the custody boundary.
  • Veil is also useful as the ecosystem canary for this verifier-artifact family: the same gamma == delta == G2 generator fingerprint later surfaced in Foom Cash, and zkSecurity’s own response after Veil was to turn the fingerprint into a chain-wide hunting rule rather than stop at postmortem narrative.
  • That makes Veil the clean first funded fingerprint reminder for verification-key statement inventory and asset-reachability discipline: the right next move was one family-level reachability sheet keyed by the malformed verifier artifact, not a one-contract incident file plus vague hopes that no sibling deployment reused it.

Internal linkages

  • Core recurring pattern: verification-key-independence-collapse.
  • Live-verifier inventory companion: verification-key-statement-inventory-and-asset-reachability-discipline — the sharper positive-design question is how teams should have known this exact verifier still controlled funded withdrawals, what artifact lineage produced it, how much value one malformed key could reach, and why one bad fingerprint should have opened one sibling-deployment quarantine sheet instead of a contract-by-contract search.
  • Companion same-fingerprint near miss: [[foom-cash-groth16-delta-gamma-setup-collapse-whitehat-rescue-2026-02-26|Foom Cash Groth16 delta == gamma setup collapse / whitehat rescue (2026-02-26)]] — useful when the sharper question is how one recognizable verifier-artifact fingerprint should escalate from incident-specific bug into cross-deployment blast-radius object.
  • Adjacent proof-statement contrast: witness-public-input-binding-failure — there the verifier key may be sound but the circuit proves the wrong statement; here the verifier key itself destroyed soundness.
  • Adjacent proof-consumer contrast: proof-to-effects-binding-failure — there the proof can be sound while settlement drifts; here the verifier would accept forged proofs before settlement logic even became the issue.

Control surface