Veil Cash Groth16 ZK Proof Forgery on Base

On February 20, 2026, the Veil Cash privacy protocol on Base was exploited for 2.9 ETH (~$5.69K) through a zero-knowledge proof forgery attack. The root cause is a misconfigured Groth16 SNARK verifier contract at 0x1e65c075989189e607ddafa30fa1a0001c376cfd where the delta verification key parameters are identical to the gamma parameters – both set to the BN128 G2 generator point. This breaks the soundness of the Groth16 pairing equation, allowing anyone to forge valid proofs for arbitrary public inputs without knowledge of the witness. The attacker deployed a contract that called withdraw() 29 times on the 0.1 ETH privacy pool, draining 2.9 ETH with fabricated nullifier hashes and forged proofs, without ever having deposited funds.

Root Cause

Vulnerable Contract

  • Name: Verifier (Groth16 SNARK verifier)
  • Address: 0x1e65c075989189e607ddafa30fa1a0001c376cfd
  • Proxy: No
  • Source type: Verified (Solidity, compiled with solc v0.8.26)
  • Source file: src/utils/Verifier.sol

The verifier was generated by snarkJS and deployed with verification key constants baked into the contract bytecode.

Vulnerable Function

  • Function: verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[6] calldata _pubSignals)
  • Selector: 0xf398789b
  • File: src/utils/Verifier.sol

Vulnerable Code

// From Verifier.sol at 0x1e65c075989189e607ddafa30fa1a0001c376cfd (verified source)

// Verification Key data
uint256 constant alphax  = 2154925384931195669696468236414102213237175831097239004580187544114565088054;
uint256 constant alphay  = 18001460744277730361809118000694905394298985948301929180248317609971584489579;
uint256 constant betax1  = 6506527127757844316976814146688351625449725845044263141394779683713824623154;
uint256 constant betax2  = 17690460444014779949496449078998668128125816378017242793701355602753621513965;
uint256 constant betay1  = 11009201094018045724233660315410925704657099711816317858836867291351802608623;
uint256 constant betay2  = 16376880945094056840819396114752708108704853396028129730069854552293465777470;
uint256 constant gammax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634;
uint256 constant gammax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
uint256 constant gammay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
uint256 constant gammay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;
uint256 constant deltax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634; // <-- VULNERABILITY: identical to gammax1
uint256 constant deltax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781; // <-- VULNERABILITY: identical to gammax2
uint256 constant deltay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531; // <-- VULNERABILITY: identical to gammay1
uint256 constant deltay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930; // <-- VULNERABILITY: identical to gammay2

The pairing check in checkPairing() computes:

// From checkPairing() in the same contract

// vk_x (linear combination of public inputs with IC points)
mstore(add(_pPairing, 384), mload(add(pMem, pVk)))
mstore(add(_pPairing, 416), mload(add(pMem, add(pVk, 32))))

// gamma2
mstore(add(_pPairing, 448), gammax1)   // <-- gamma G2 point
mstore(add(_pPairing, 480), gammax2)
mstore(add(_pPairing, 512), gammay1)
mstore(add(_pPairing, 544), gammay2)

// C
mstore(add(_pPairing, 576), calldataload(pC))
mstore(add(_pPairing, 608), calldataload(add(pC, 32)))

// delta2
mstore(add(_pPairing, 640), deltax1)   // <-- VULNERABILITY: same as gamma
mstore(add(_pPairing, 672), deltax2)
mstore(add(_pPairing, 704), deltay1)
mstore(add(_pPairing, 736), deltay2)

let success := staticcall(sub(gas(), 2000), 8, _pPairing, 768, _pPairing, 0x20) // ecPairing precompile

Why It’s Vulnerable

Expected behavior: In a correct Groth16 setup, delta and gamma are distinct G2 group elements derived from the trusted setup ceremony. The Groth16 verification equation is:

e(-A, B) * e(alpha, beta) * e(vk_x, gamma) * e(C, delta) = 1

Where delta and gamma serve fundamentally different roles: gamma binds the public inputs to the proof, and delta binds the private witness (via the proof element C). They MUST be independent group elements for the proof system to be sound.

Actual behavior: Both delta and gamma are set to the same value – specifically, the BN128 G2 generator point [1]_2. When delta == gamma, the pairing equation becomes:

e(-A, B) * e(alpha, beta) * e(vk_x, gamma) * e(C, gamma) = 1
e(-A, B) * e(alpha, beta) * e(vk_x + C, gamma) = 1

This means the proof element C can absorb the contribution of vk_x (which encodes the public inputs). An attacker can choose C such that vk_x + C equals any desired value, completely decoupling the public inputs from the pairing check. In practice, by setting C = -vk_x (computing the elliptic curve negation of the linear combination of public inputs), the attacker can make the public input contribution vanish entirely. The equation then reduces to:

e(-A, B) * e(alpha, beta) = 1

Which can be satisfied by setting A = alpha and B = beta (or any scalar multiple thereof), requiring zero knowledge of the witness.

Normal flow vs Attack flow:

  • Normal flow: A legitimate user generates a Groth16 proof using the ZK circuit’s witness (secret note, nullifier, Merkle path). The proof (A, B, C) is cryptographically bound to the specific public inputs (root, nullifier hash, recipient, etc.) through the distinct gamma and delta parameters.

  • Attack flow: The attacker sets A to a fixed point, B to a corresponding fixed point that satisfies e(A, B) = e(alpha, beta), and computes C as the negation of the vk_x linear combination for the desired public inputs. Since delta == gamma, the C element perfectly cancels out the public input contribution, making the proof valid for ANY public inputs without any witness.

Downstream Impact on Pool Contract

The pool contract Veil_01_ETH at 0xd3560ef60dd06e27b699372c3da1b741c80b7d90 calls this verifier in its withdraw() function:

// From Veil_01_ETH.sol (verified source)

function withdraw(
    uint256[2] calldata _pA,
    uint256[2][2] calldata _pB,
    uint256[2] calldata _pC,
    bytes32 _root,
    bytes32 _nullifierHash,
    address _recipient,
    address _relayer,
    uint256 _fee,
    uint256 _refund
) external payable nonReentrant {
    require(_fee <= denomination, "Fee exceeds transfer value");
    require(!nullifierHashes[_nullifierHash], "The note has been already spent");
    require(isKnownRoot(_root), "Cannot find your merkle root");
    require(
        verifier.verifyProof(                    // <-- calls the broken verifier
            _pA,
            _pB,
            _pC,
            [
                uint256(_root),
                uint256(_nullifierHash),
                uint256(uint160(_recipient)),
                uint256(uint160(_relayer)),
                _fee,
                _refund
            ]
        ),
        "Invalid withdraw proof"
    );

    nullifierHashes[_nullifierHash] = true;      // marks nullifier as spent
    _processWithdraw(_recipient, _relayer, _fee, _refund);
    emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee, block.timestamp);
}

The pool’s own checks are sound in isolation – it verifies the root is known, the nullifier hash has not been spent, and the fee is within bounds. But because the ZK proof verification is broken, the attacker can pass any valid Merkle root (queried from the pool), any novel nullifier hash, and a forged proof, to withdraw 0.1 ETH per call without ever having deposited.

Attack Execution

High-Level Flow

  1. Attacker EOA (0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903) deploys an attack contract via CREATE.
  2. Attack contract queries the pool’s current Merkle root via getLastRoot().
  3. Attack contract verifies the root is valid via isKnownRoot(root).
  4. Attack contract queries the pool’s denomination (0.1 ETH) and computes the pool balance to determine how many withdrawals are possible (pool balance / denomination = 29).
  5. Attack contract enters a loop of 29 iterations, each with a unique fabricated nullifier hash (0xdead0000 through 0xdead001c).
  6. For each iteration, the attack contract computes the vk_x linear combination on-chain using the BN128 ecMul and ecAdd precompiles, then computes the forged proof elements (A, B, C) where C negates the vk_x contribution.
  7. The attack contract calls Veil_01_ETH.withdraw() with the forged proof, the valid root, the fabricated nullifier, and the attacker EOA as recipient.
  8. Inside withdraw(), the pool calls Verifier.verifyProof() which returns true because the proof satisfies the broken pairing equation.
  9. The pool sends 0.1 ETH to the attacker EOA and marks the nullifier as spent.
  10. After all 29 withdrawals, the attack contract self-destructs, forwarding any remaining balance to the attacker EOA.

Detailed Call Trace

The trace shows a CREATE transaction from 0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903 deploying to 0x5f68ad46f500949fa7e94971441f279a85cb3354.

AttackerEOA (0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903)
  -> CREATE -> AttackerContract (0x5f68ad46f500949fa7e94971441f279a85cb3354)
     |
     |-- STATICCALL -> VeilCashPool (0xd3560ef60dd06e27b699372c3da1b741c80b7d90)
     |   getLastRoot() [0xba70f757]
     |   returns: 0x2e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c063
     |
     |-- STATICCALL -> VeilCashPool
     |   isKnownRoot(bytes32) [0x6d9833e3]
     |   returns: true
     |
     |-- STATICCALL -> VeilCashPool
     |   denomination() [0x8bca6d16]
     |   returns: 100000000000000000 (0.1 ETH)
     |
     |-- [On-chain proof computation: ecMul(0x07) + ecAdd(0x06) for vk_x of first nullifier 0xdead0000]
     |
     |-- CALL -> VeilCashPool
     |   withdraw(...) [0x2aa4eeec]  (iteration 1, nullifier: 0xdead0000)
     |   |
     |   |-- STATICCALL -> Verifier (0x1e65c075989189e607ddafa30fa1a0001c376cfd)
     |   |   verifyProof(...) [0xf398789b]
     |   |   |-- ecMul(0x07) x6, ecAdd(0x06) x6  [vk_x computation]
     |   |   |-- ecPairing(0x08)                   [pairing check -> returns 1 (true)]
     |   |   returns: true
     |   |
     |   |-- CALL -> AttackerEOA (0x49a7...f903)
     |       value: 0.1 ETH (0x16345785d8a0000)    [pool sends denomination to recipient]
     |
     |-- [ecMul + ecAdd for next nullifier 0xdead0001]
     |-- CALL -> VeilCashPool: withdraw(...) (iteration 2, nullifier: 0xdead0001)
     |   [same pattern: verifyProof -> true -> send 0.1 ETH]
     |
     |-- ... (iterations 3-28 follow identical pattern)
     |
     |-- CALL -> VeilCashPool: withdraw(...) (iteration 29, nullifier: 0xdead001c)
     |   [same pattern: verifyProof -> true -> send 0.1 ETH]
     |
     |-- SELFDESTRUCT -> AttackerEOA

Each of the 29 withdraw() calls follows an identical pattern:

  • Attacker contract computes forged proof off-loop constants then per-iteration vk_x via ecMul/ecAdd precompiles
  • Calls VeilCashPool.withdraw() with forged (A, B, C), the known Merkle root, a unique sequential nullifier (0xdead0000 + i), and the attacker EOA as recipient
  • Inside withdraw: verifier’s verifyProof() calls ecMul (precompile 0x07) 6 times, ecAdd (precompile 0x06) 6 times, and ecPairing (precompile 0x08) once, all returning success
  • Pool sends 0.1 ETH to 0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903

The verifyProof() call returns 0x0000...0001 (true) for every iteration, confirming the forged proofs pass the broken pairing check.

Financial Impact

  • Total stolen: 2.9 ETH (29 withdrawals x 0.1 ETH denomination)
  • USD equivalent: ~$5,690 (at ETH ~$1,962 on 2026-02-20)
  • Gas cost to attacker: ~0.000076 ETH (9,183,027 gas at ~0.008 gwei base fee)
  • Net attacker profit: ~2.8999 ETH
  • Source of funds: funds_flow.json confirms 29 ETH transfers of 0.1 ETH each from pool 0xd3560ef60dd06e27b699372c3da1b741c80b7d90 to attacker EOA 0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903, totaling 2.9 ETH.
  • Who lost funds: Legitimate depositors of the Veil Cash 0.1 ETH privacy pool. The pool was drained of 2.9 ETH (all available balance).
  • Protocol solvency: The 0.1 ETH pool was fully drained. Any remaining legitimate deposits in this pool cannot be withdrawn. Other denomination pools (if they share the same verifier) would be similarly vulnerable.

Evidence

Gamma == Delta Verification

From the verified source of Verifier.sol at 0x1e65c075989189e607ddafa30fa1a0001c376cfd:

ParameterGamma ValueDelta ValueMatch
x11155973203298638710799100402139228578392581286182119253091740315145239180563411559732032986387107991004021392285783925812861821192530917403151452391805634YES
x21085704699902305713594457076223282948137075635957851808699051999328565585278110857046999023057135944570762232829481370756359578518086990519993285655852781YES
y140823678758634336813322034031454355683168513275934012081057410762141200935314082367875863433681332203403145435568316851327593401208105741076214120093531YES
y284956539231234314176049732474892724384181905872636001487702806493069581019308495653923123431417604973247489272438418190587263600148770280649306958101930YES

These values match the standard BN128 (alt_bn128) G2 generator point, confirming that the trusted setup’s delta contribution was never applied – the verification key was deployed with the initial (insecure) ceremony state where delta = gamma = [1]_2.

Selector Verification

SelectorFunctionVerified
0x2aa4eeecwithdraw(uint256[2],uint256[2][2],uint256[2],bytes32,bytes32,address,address,uint256,uint256)cast sig confirmed
0xf398789bverifyProof(uint256[2],uint256[2][2],uint256[2],uint256[6])cast sig confirmed
0xba70f757getLastRoot()cast sig confirmed
0x6d9833e3isKnownRoot(bytes32)cast sig confirmed
0x8bca6d16denomination()cast sig confirmed

Key Events

The Withdrawal event (0x6d7aac54bd3d1c91db3b1fd7b8d6cb45324ad6b5e373c0f0ea4d7b2606c4c2c8) was emitted 29 times from the pool contract, each with:

  • to (indexed): 0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903 (attacker EOA)
  • relayer (indexed): 0x0000000000000000000000000000000000000000 (no relayer)
  • nullifierHash: sequential from 0xdead0000 to 0xdead001c
  • fee: 0
  • timestamp: 1771524581 (2026-02-20T18:09:41 UTC)

Transaction Receipt

  • Status: Success (0x1)
  • Block: 42,410,817 (0x2872341)
  • Gas used: 9,183,027 (0x8c1f33)

Forged Proof Structure

From the first withdraw() calldata, the proof elements are:

  • A (pA): [0x04c3a500..., 0x27cc7739...] – a fixed G1 point
  • B (pB): [[0x0e629058..., 0x271c721e...], [0x1856fcac..., 0x2434fc73...]] – a fixed G2 point
  • C (pC): computed per-iteration as the negation of vk_x for each set of public inputs

The attacker contract performs the vk_x computation on-chain using the same IC constants as the verifier (visible in the ecMul/ecAdd precompile calls to precompiles 0x06 and 0x07 in the trace), then negates the result to produce C.