Root Cause
Hinkal is an on-chain privacy protocol that uses zero-knowledge proofs to shield transaction details. Its contract exposes a prooflessDeposit() entry point that was designed for specific operational flows. The exploit demonstrates this function failed to enforce ZK proof validation on-chain — or lacked an adequate access gate — allowing an externally owned account to register a shielded note without supplying a cryptographic proof.
Once a note was registered without a proof, the attacker used the normal transact() function — which is designed to consume shielded notes — to drain USDC belonging to legitimate users.
// Hinkal (reconstructed from post-incident reports)
// Vulnerability: no on-chain gate enforces that only privileged callers
// (or a valid proof) may reach prooflessDeposit()
function prooflessDeposit(
address token,
uint256 amount,
bytes32 commitment
) external {
// Missing: require(hasRole(OPERATOR_ROLE, msg.sender), "unauthorized");
// Missing: or a ZK proof that the depositor owns the commitment
_insertLeaf(commitment); // note silently added to Merkle tree
IERC20(token).transferFrom(msg.sender, address(this), amount);
}
function transact(
bytes calldata zkProof,
bytes32[] calldata nullifiers,
bytes32[] calldata newCommitments,
address token,
uint256 amount
) external {
// Normal withdrawal path — consumes valid Merkle notes
// Attacker's proofless note is indistinguishable from a real one here
_verifyProof(zkProof, ...);
_processTransfer(token, amount, ...);
}
Hinkal's investigation confirmed a "proofless deposit" was the initial entry point, but the full technical post-mortem was still pending at time of publication.
Attack Steps
| Step | Action | Detail |
|---|---|---|
| 1 | Attacker EOA 0xbB3f01a1b1C68F3DEB36C55342b5F5706c32fc20 calls prooflessDeposit() | No valid ZK proof submitted |
| 2 | A commitment is inserted into Hinkal's Merkle tree without cryptographic validation | Bypasses privacy model entirely |
| 3 | Multiple transact() calls consume notes against the illegitimate commitment, draining the USDC pool | ~$820K USDC drained across ~85 calls |
| 4 | Attacker swaps stolen USDC to ETH on-chain | — |
| 5 | 410 ETH (~$700K) deposited into Tornado Cash for laundering | Cross-protocol obfuscation |
| 6 | 44.7 ETH bridged from Ethereum to Bitcoin via THORChain | Cross-chain laundering |
Impact
- ~$820,000 USDC drained — approximately 99% of Hinkal's total TVL ($829K across five chains) at time of attack.
- Exploit was isolated to Hinkal's Ethereum deployment; other chain deployments (Polygon, BNB Chain, etc.) were not affected.
- Stolen funds laundered within hours through Tornado Cash and THORChain, complicating recovery.
- Protocol TVL effectively zeroed on the primary chain.
Lessons for Auditors
- Every deposit path into a ZK-proof system must enforce proof verification. Functions named
prooflessDeposit()are a critical red flag — they must be guarded by a strong on-chain role (multisig, time-lock, or operator role) that cannot be spoofed by an arbitrary EOA. - ZK privacy models do not replace standard access control. The cryptographic proof model secures the transact flow; it does not automatically secure all entry points into the note commitment tree.
- Test proofless entry points for drain composability. Verify that a proofless/privileged deposit cannot subsequently be consumed by a standard
transact()call that draws from the general liquidity pool — i.e., that note isolation is enforced. - 100% TVL drain in a single Merkle-based call sequence is the worst case. Privacy protocols that hold fungible assets on behalf of many users need shielded-note isolation per depositor; a shared pool with a single unguarded entry point concentrates all risk.
- Monitor privileged entry points post-deployment. Any function that can insert a note without a full ZK proof should trigger an alert when called by a non-whitelisted address.