Skip to content
All notes
Incident protocol-wide · 773,092.64 USDC
13 min read zkprivacypost-mortemincident

Hinkal shielded-pool drain: phantom notes from an unsound ZK proof

A forensic writeup of the Hinkal USDC shielded-pool drain: five attacker addresses deposited 27,900 USDC, withdrew 800,992.64 USDC, and emptied the pool through proofs that appeared valid but failed to conserve hidden note value.

Security Incident Analysis — Hinkal Protocol Shielded-Pool Drain

A post-incident forensic and root-cause report

FieldValue
SubjectUnauthorized drain of the Hinkal shielded pool (USDC), Ethereum mainnet
Affected contract0x25e5e82f5702a27c3466fe68f14abdbbadfca826 (Hinkal core)
Incident window2026-07-02 19:56 UTC (first exploit tx) → 2026-07-03 00:16 UTC (drain complete); contained 04:46 UTC
Net loss773,092.64 USDC (≈ the entire pool)
SeverityCritical — complete, permissionless loss of pooled principal
Root causeSoundness failure in the off-chain zero-knowledge withdrawal circuit / verifying key; the on-chain contract enforces no value conservation independent of the proof
StatusContained (protocol paused by the owner)
ConfidenceRoot-cause locus — high (proven by elimination + empirical replay). Exact missing constraint — undetermined (requires off-chain circuit source).
Report date2026-07-03

1. Executive summary

An attacker withdrew essentially the entire USDC balance of the Hinkal shielded pool by abusing the protocol’s normal transact entry point. The pool held 773,098.03 USDC of legitimate deposits immediately before the incident and 5.39 USDC after. Across a cluster of five addresses the attacker deposited only 27,900 USDC of real funds and withdrew 800,992.64 USDC — a net theft of 773,092.64 USDC, taken from other depositors’ principal.

The incident is not a Solidity coding bug, an admin-key compromise, a signature flaw, or a verifier swap. All of those were investigated and excluded. The failure is architectural and cryptographic: Hinkal delegates 100 % of value-conservation to a zero-knowledge proof, and that proof system is unsound. A genuine, correctly-functioning Groth16 verifier accepted proofs that let the attacker create shielded value from nothing (“phantom notes”) and redeem it for real USDC.

The observed technique, confirmed across all five attacker addresses:

  1. Deposit once (prooflessDeposit) to seed a single legitimate note.
  2. Repeatedly call transact with zero external USDC movement — each call submits a valid proof that mints output notes worth more than the input notes it consumes (~310 of 422 pool-outbound transfers were zero-value; ~62 such calls per attacker address).
  3. Withdraw the accumulated phantom balance as real USDC; the primary address extracted it in 25,000-USDC chunks.

The team contained the incident ~4.5 hours after the drain completed by setting the pool’s helper contract to address(0), which reverts every transact.

Systemic implication: because the flaw is in the proving system, every asset pool served by the same circuit family is exposed. The blanket pause was the correct mitigation. Recovery of funds is not achievable on-chain.


2. Severity and classification

DimensionAssessment
ImpactCritical — total loss of pooled principal (100 % of USDC pool)
ExploitabilityTrivial once known — permissionless, no privileged access, no race, repeatable
PreconditionsNone beyond a funded EOA and the ability to generate the malicious proof
Detectability (pre-exploit)Low — requires circuit-level review; on-chain behavior looks like normal shielded activity
ClassUnder-constrained ZK circuit / unsound verifying key (a recognized top-tier ZK vulnerability class) — compounded by absence of an on-chain value-conservation invariant (defense-in-depth failure)

3. Affected system overview

Hinkal is a privacy (shielded-pool) protocol. Users deposit ERC-20s and hold value as commitments (“notes”) in an on-chain Merkle tree; spends are authorized by zk-SNARK proofs and protected against double-spend by nullifiers.

Contract topology (verified on-chain):

ComponentAddressVerified source?Role
Core (Hinkal)0x25e5e82f5702a27c3466fe68f14abdbbadfca826Entry point (transact, prooflessDeposit); holds the money and the Merkle tree
Helper (HinkalHelper)0xF498fa8b0C1328a22f5d3764DcaB712728d12562Builds the proof’s public inputs; input validation
In-logic (HinkalInLogic)0xde88ff7ba99ac02109f4604bef2b48e01be27a4bdelegatecall target for deposit/internal accounting
Verifier forwarder0x163fe0B047ef9B4553E2AfF620AE3020883DFec5 (dims 1,2,1)❌ bytecode onlyRoutes verifyProof to the real verifier
Groth16 verifier0x2617f5b358dd51fe8e2357b0ef80254e8d515d74❌ bytecode onlybn254 pairing checks + embedded verifying key
Owner (EOA)0xbdc77a0c69f13207acb70a6981cad60b4c1d1942Holds onlyOwner powers; executed the pause

Notes established by direct verification: the core is not a delegating proxy (EIP-1967 implementation slot is empty; it holds its own verified bytecode — Etherscan’s “implementation 0x94c8…” label is a heuristic pointing at an older HinkalInLogic). The circuit source and verifying key are not published — the verifier embeds the VK as opaque constants and its source is unverified.


4. Incident timeline (UTC)

TimeBlockEvent
2026-07-02 19:02:1125,446,788Attacker EOA 0x105ad1…d7c0 funded with 0.257 ETH (its first-ever tx)
2026-07-02 19:56:3525,447,059First exploit txprooflessDeposit by 0x105ad1 (tx 0xb6dab069…54d9); begins 3 deposits (100 USDC total) + 190 transacts, withdraws 0 — R&D / calibration
2026-07-02 20:08:4725,447,1190x105ad1 prooflessDeposit 0xfbedf0cd…2f11 (a later deposit in its loop)
2026-07-02 22:13 – ~00:0025,447,737 – ~25,448,300Three more addresses (0x3e15, 0xc75d, 0xd74c) run near break-even loops — further calibration; each deposits ~1,000 USDC
2026-07-03 00:07:2325,448,306Primary address 0xbb3f…fc20 deposits 25,000 USDC and starts its run
2026-07-03 ~00:15–00:1625,448,345 – 25,448,351Bulk extraction: 30 × 25,000-USDC withdrawals to 0xbb3f; pool emptied
2026-07-03 00:16:2325,448,351Attacker activity ends; pool ≈ 5.39 USDC
2026-07-03 ~00:2125,448,374Launder: 797,000 USDC → ~454 WETH (Uniswap Universal Router), unwrapped to ETH
2026-07-03 ~00:43–00:4825,448,486–510Launder: 410 ETH into Tornado Cash (11×10 + 3×100)
2026-07-03 ~01:2225,448,683Launder: 44.67 ETH → BTC via THORChain → bc1qr2sf…zn3w
2026-07-03 04:46:3525,449,694Containment — owner calls setHinkalHelper(address(0)) (tx 0x05c1f7ca…bb60c); all transact now revert (~3.5 h after the attacker had fully exited)

Balance ground truth (USDC balanceOf the pool):

BlockPool USDC
25,445,000 – 25,447,118 (pre-attack, legit, stable)773,098.03
25,447,532 (after early attacker deposits)773,198.03
25,448,310 (mid, pre-bulk)765,798.03
25,449,694 (pause) & latest5.39

5. Impact assessment

  • Direct loss: 773,092.64 USDC, reconciled exactly two independent ways (event-sum vs. balance snapshots): 773,098.03 (legit pre-attack) + 27,900.00 (attacker deposits, 5 addresses) − 800,992.64 (attacker withdrawals) = 5.39 (post).
  • Scope: the entire pre-existing USDC pool — third-party depositors’ principal — was taken. Attacker net gain ≈ the whole pool.
  • Beneficiary concentration: 0xbb3f netted +772,000 of the 773,092.64.
  • Recoverability: effectively none for the USDC — proceeds were swapped, mixed through Tornado Cash, and partially bridged to Bitcoin (§5.1) before the protocol was even paused.

Assets drained — the exploit is circuit-wide, not USDC-specific:

AssetPre-attack poolAttacker inOutNet lossStatus
USDC773,098.0327,900800,992.64−773,092.64 USDCfully drained
ETH (native)~10.91 ETH~0.01~0.40 ETH−~0.39 ETH (~$0.7k)partially drained; ~10.52 ETH remains

The attacker fully monetized the USDC pool and took a small amount from the ETH pool (sub-1-ETH test withdrawals). Every Hinkal pool remains at risk until the circuit is fixed — the surviving ETH balance is protected only by the pause.

5.1 Post-incident fund flow (laundering trail)

The primary address 0xbb3f laundered the proceeds within ~1 hour of the drain — and ~3.5 hours before containment — traced end-to-end on-chain:

StepActionAmountDestination / tx
1Swap USDC → WETH (Uniswap Universal Router) then unwrap to ETH797,000 USDC → 454.24 WETHrouter 0x4c82d1fbfe28c977cbb58d8c7ff8fcf9f70a2cca, tx 0x3d57b6f0…8036
2Deposit to Tornado Cash (14 deposits: 11×10 ETH + 3×100 ETH)410 ETHTornado Router 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b, blocks 25,448,486–25,448,510
3Bridge to Bitcoin via THORChain44.6747 ETHTHORChain Router 0xd37bbe5744D730a1d98d8DC97c42F0Ca46aD7146, tx 0x14dd7a30…6deb

The THORChain deposit memo — =:b:bc1qr2sfkehuqgr0sp87sp25uzw79242523l26zn3w:122370548/0/25:sto:0 — is a swap to native BTC delivered to Bitcoin address bc1qr2sfkehuqgr0sp87sp25uzw79242523l26zn3w. In total ≈ 454.7 ETH left via Tornado Cash (410) and a cross-chain BTC bridge (44.7). The attacker had fully exited before the protocol was paused (04:46 UTC).


6. Attack analysis

6.1 Threat actor and infrastructure

Five coordinated EOAs, each exhibiting the identical pattern (prooflessDeposit + many transact), plus three withdrawal-only payout addresses:

AddressDepositedWithdrawnNetTx patternRole
0x105ad144e8952236144dfe2e135ced7812e3d7c01000−1003 deposit + 190 transactfirst prober / R&D (from 19:56 UTC; never cashed out)
0xd74c58561adf1e9a10e7ad67fb5920d7c20ccaa48001,000+2001 deposit + 62 transactprobe
0x3e15f3e96115735340f03d6044e0d6c21f8f60561,0001,200+2001 deposit + 94 transactprobe
0xc75d0b625570f3dbe81f329e516e41a29f0644dc1,0001,00001 deposit + 94 transactprobe
0xbb3f01a1b1c68f3deb36c55342b5f5706c32fc2025,000797,000+772,0001 deposit + 94 transactprimary drainer
3 payout-only addrs (0xbe4c2d07…, 0xe42ce2de…, 0xb8d5e90d…)0792.64+792.64withdrawal recipientstest/dust destinations
Total27,900800,992.64+773,092.64

6.2 Kill chain

  • Phase 1 — Seed (one honest deposit). A single prooflessDeposit (0x0ed4a94e) transfers real USDC in and mints one legitimate note, establishing a valid Merkle root to spend against. This is the only honest value transfer.
  • Phase 2 — Mint (repeated zero-value transacts). The bulk of activity is transact (0x979a77a8) calls that move 0 USDC externally. Each supplies a valid proof creating output-note commitments whose hidden value exceeds the input notes being nullified. Because value conservation is enforced only by the proof (§7), each call mints phantom shielded balance at no cost. Empirically, ~310 of 422 pool-outbound USDC transfers were zero-value (~62 per attacker address).
  • Phase 3 — Extract (withdrawals). A smaller set of transact calls carry negative amountChanges, releasing real USDC to external addresses. Nonzero withdrawal distribution: 25,000 × 30, 1,000 × 47, 100 × 32, plus dust — the whale 0xbb3f pulled 797,000, dominated by the 25k chunks.

6.3 Mapping to the three sample transactions

TxFunctionMeaning
0x26c1a6…4630prooflessDeposit (1,000 USDC in)Phase 1 — seed one note
0x6092eb…896ftransact0 USDC external, 1 Nullified + 1 NewCommitmentPhase 2 — phantom mint (repeated)
0x753e0a…c1b6transact — 1,000 USDC out to a fresh address, amountChanges = -1000e6Phase 3 — withdrawal

7. Root cause analysis

7.1 Where value conservation is (not) enforced

transact() gates every call on four checks. Only the zk proof actually constrains value:

  1. Proof verificationrequire(verifyProof(a,b,c, publicInputs, verifierId)).
  2. Root validityrequire(rootHashExists(rootHashHinkal)) (trivially satisfiable after one deposit).
  3. Per-token balance equationbalanceDif == amountChanges[i] + utxoAmount + approvalChanges + ….
  4. Nullifier uniquenessrequire(!nullifiers[n]), then mark spent.

The design intent is that (1) proves value conservation among hidden notes, while (2)–(4) bind the proof to on-chain state.

7.2 Why the on-chain guards cannot catch this

  • The balance equation is a tautology for withdrawals. With no on-chain change note and approvals deprecated, check (3) reduces to balanceDif == amountChanges[i]-sumAbs == -sumAbs. It only confirms the tokens that left equal the number the caller claimed would leave — never that the burned note actually held that value. The core stores no per-note balances; note values are hidden inside commitments (Poseidon hashes), opaque to the contract.
  • Nullifier uniqueness only blocks replay of the same note. A valid-looking proof can present a fresh nullifier each call — consistent with the many distinct nullifiers observed. The double-spend guard is correct but out of scope for this attack.
  • Consequence: the sole real barrier to withdrawing more than deposited is the proof. If the proof system is unsound, nothing on-chain contains the loss. This is a defense-in-depth failure: there is no independent on-chain invariant bounding withdrawals by deposits.

7.3 Hypothesis testing — causes investigated and excluded

HypothesisVerdictEvidence
Solidity logic bug in the coreExcludedHinkal, HinkalHelper, HinkalInLogic are all verified and internally consistent; transact flow reviewed line-by-line
Faulty public-input assembly (helper)ExcludedVerified HinkalHelper.formBasicInput binds rootHash, amountChanges, inputNullifiers, outCommitments, timeStamp, calldataHash as public signals; performHinkalChecks enforces getHashedCalldata == calldataHash (calldata integrity); dimensionsCheck forces verifier selection to match array lengths
Verifier is a no-op / stubExcludedReal ~2.6 KB Groth16 verifier: bn254 pairing precompiles (0x06/0x07/0x08), embedded field prime and VK constants
Verifier swapped / owner compromise near attackExcludedFull history: 117 VerifierRegistered, 0 VerifierRemoved; last registration block 25,285,711 (~3 weeks prior); ownership last changed block 23,119,995 — none near the incident
Wrong code analyzed (proxy indirection)ExcludedEIP-1967 implementation slot is empty; core holds its own verified bytecode
Under-constrained circuit / unsound verifying keyConfirmed locusSee §7.4–7.5

Residual caveat: a verifier registered ~3 weeks before the attack could in principle have been intentionally backdoored at registration; this is still an “off-chain zk” fault (with owner implication) and cannot be distinguished from an honest-but-unsound circuit using on-chain data alone.

7.4 Confirmed root cause

A soundness failure in the off-chain zk withdrawal circuit / verifying key. The registered Groth16 verifier accepted proofs for shielded spends/outputs that violate value conservation (Σ input-note value == Σ output-note value + withdrawal). This lets an attacker mint phantom notes and redeem them. With every auditable layer verified sound, the defect necessarily resides in the one unpublished component: the circuit and its VK — i.e., an under-constrained circuit (a missing value-conservation and/or Merkle-membership constraint) or a compromised/incorrect trusted setup.

Logical chain:

transact succeeded ⇒ a real Groth16 verifier accepted the proof ⇒ yet withdrawals exceeded deposits (value was minted) ⇒ the proven statement does not enforce value conservation ⇒ the circuit is under-constrained or its VK/trusted setup is unsound.

7.5 Empirical verification (proof replay)

The critical link — “a real verifier accepted the attacker’s proof” — was reproduced on-chain, not merely inferred:

  1. Recovered the proof (a,b,c) from a real attacker transact (0x753e0a…c1b6).
  2. Reconstructed the exact 14 public signals by calling HinkalHelper.performHinkalChecks(circomData, dimensions, sender) on-chain at the tx’s block (25,448,295).
  3. Called verifierMap[verifierId].verifyProof(a,b,c,input,verifierId) at the same block → returned true.
  4. Control: flipped a single public-input bit → returned false.

The control proves the verifier performs genuine cryptographic checking (not a rubber stamp). Therefore the attacker’s proof was a valid Groth16 proof — for a statement that fails to enforce value conservation.

7.6 Domain context

Under-constrained circuits are a recognized top-tier vulnerability class in ZK systems: a circuit that is missing a constraint accepts witnesses that satisfy the (incomplete) relation while violating the intended invariant, and the verifier — doing exactly its job — returns “valid.” A verifying key is a one-way commitment to the circuit: it cannot be reversed to recover the constraints, which is precisely why the specific defect is not determinable from on-chain bytecode. In a shielded pool the canonical missing constraints are (a) balance/value conservation across inputs and outputs, and (b) Merkle-membership of every spent note. Either omission produces exactly the behavior observed here.


8. Evidence & methodology (reproducibility and calibration)

Data sources: Etherscan V2 API (source, ABIs, tx/receipt/logs); Infura archive RPC (historical balanceOf, storage/getter reads, eth_getLogs, historical eth_call); cast (Foundry) for decoding and calls.

Key techniques:

  • Loss quantified authoritatively via eth_getLogs on the USDC Transfer topic filtered by the pool address, cross-checked against balanceOf snapshots — agreement to the cent (5.39 residual).
  • Attacker attribution via per-address txlist filtered to the pool (selector classification).
  • Helper timeline and verifier registration via historical eth_call and full-history getLogs.
  • Verifier authenticity via bytecode disassembly (opcode profile, precompile usage, VK constants).
  • Proof acceptance via on-chain replay with a tampered-input control.

Calibration:

  • Proven (hard on-chain evidence): loss magnitude and reconciliation; attacker addresses and tx pattern; zero-value-transact prevalence; contract topology and verification status; no verifier/ownership change near the incident; the tautological balance check and nullifier scope; verifier accepts the real proof / rejects tampered input.
  • Inferred (strong, by elimination + logic): the fault is in the off-chain circuit/VK rather than any on-chain component.
  • Undetermined (not obtainable on-chain): the specific missing constraint, and whether the fault is an under-constrained circuit vs. a compromised trusted setup. Resolving these requires the off-chain circuit source and ceremony artifacts. (Scope note: this analysis covered Hinkal’s Ethereum USDC and ETH pools; pools of other tokens or on other chains were not examined and should be checked by the team.)

9. Detection & containment

  • Action: owner 0xbdc77a0c… called setHinkalHelper(address(0)) at block 25,449,694 (2026-07-03 04:46:35 UTC).
  • Mechanism: performHinkalChecks is invoked at the top of both transact and prooflessDeposit; with the helper at address(0) the external call reverts (empty-account check), halting every fund-touching path in a single transaction. The helper was used as a kill-switch, not because it was faulty — they set it to 0 (halt), not to a corrected helper. Verified empirically: after the pause block the only transaction the pool received is that setHinkalHelper call.
  • Gap: the drain completed in ~9 minutes of concentrated activity, ~4.5 hours before containment. The pause limited further loss but did not prevent the primary drain. There is no dedicated pause(); a single owner EOA halted the protocol unilaterally.

More notes