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
Ato a fixed point,Bto a corresponding fixed point that satisfiese(A, B) = e(alpha, beta), and computesCas the negation of thevk_xlinear combination for the desired public inputs. Sincedelta == 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
- Attacker EOA (
0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903) deploys an attack contract via CREATE. - Attack contract queries the pool’s current Merkle root via
getLastRoot(). - Attack contract verifies the root is valid via
isKnownRoot(root). - 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).
- Attack contract enters a loop of 29 iterations, each with a unique fabricated nullifier hash (
0xdead0000through0xdead001c). - For each iteration, the attack contract computes the
vk_xlinear 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. - The attack contract calls
Veil_01_ETH.withdraw()with the forged proof, the valid root, the fabricated nullifier, and the attacker EOA as recipient. - Inside
withdraw(), the pool callsVerifier.verifyProof()which returnstruebecause the proof satisfies the broken pairing equation. - The pool sends 0.1 ETH to the attacker EOA and marks the nullifier as spent.
- 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.jsonconfirms 29 ETH transfers of 0.1 ETH each from pool0xd3560ef60dd06e27b699372c3da1b741c80b7d90to attacker EOA0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903, 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:
| Parameter | Gamma Value | Delta Value | Match |
|---|---|---|---|
| x1 | 11559732032986387107991004021392285783925812861821192530917403151452391805634 | 11559732032986387107991004021392285783925812861821192530917403151452391805634 | YES |
| x2 | 10857046999023057135944570762232829481370756359578518086990519993285655852781 | 10857046999023057135944570762232829481370756359578518086990519993285655852781 | YES |
| y1 | 4082367875863433681332203403145435568316851327593401208105741076214120093531 | 4082367875863433681332203403145435568316851327593401208105741076214120093531 | YES |
| y2 | 8495653923123431417604973247489272438418190587263600148770280649306958101930 | 8495653923123431417604973247489272438418190587263600148770280649306958101930 | YES |
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
| Selector | Function | Verified |
|---|---|---|
0x2aa4eeec | withdraw(uint256[2],uint256[2][2],uint256[2],bytes32,bytes32,address,address,uint256,uint256) | cast sig confirmed |
0xf398789b | verifyProof(uint256[2],uint256[2][2],uint256[2],uint256[6]) | cast sig confirmed |
0xba70f757 | getLastRoot() | cast sig confirmed |
0x6d9833e3 | isKnownRoot(bytes32) | cast sig confirmed |
0x8bca6d16 | denomination() | 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 from0xdead0000to0xdead001cfee: 0timestamp: 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.
Related URLs
- Base transaction: https://basescan.org/tx/0x5ff6dbc33e77fab8dc086bb9ea3c88f1ba81df198d24ec9fc0c5b50fb1a4a17d
- Verifier contract: https://basescan.org/address/0x1e65c075989189e607ddafa30fa1a0001c376cfd
- Pool contract: https://basescan.org/address/0xd3560ef60dd06e27b699372c3da1b741c80b7d90
- Attacker EOA: https://basescan.org/address/0x49a7ca88094b59b15eaa28c8c6d9bfab78d5f903