A user who finds a valid treasure calls claim(proof, treasureHash, recipient), which verifies a ZK proof and marks the treasure as claimed so it cannot be paid out twice.
claim() reads the already-claimed flag from the uninitialized immutable _treasureHash (which is always bytes32(0)) instead of the treasureHash parameter. _markClaimed writes to claimed[treasureHash], so the guard never fires. A single valid proof can be replayed up to MAX_TREASURES times with different recipients, draining the entire reward pool.
Likelihood:
Occurs on every claim attempt because the bug is in the hot path of claim(); any holder of a valid secret can exploit it without special conditions.
A single treasure secret found in the real world is sufficient — the attacker only needs to regenerate proofs locally with different recipient public inputs.
Impact:
Direct theft of the entire contract balance (100 ETH with the default funding of 10 treasures × 10 ETH).
Honest finders of the remaining treasures revert with NotEnoughFunds / AllTreasuresClaimed, so the hunt's economic model is broken.
The bug is directly observable from the storage layout: _treasureHash is declared immutable on line 35 but never written inside the constructor (lines 67–75). Solidity initializes unassigned immutables to the zero value, so every read of _treasureHash yields bytes32(0). Line 88's duplicate-claim guard therefore always evaluates claimed[bytes32(0)], which is never written because _markClaimed(treasureHash) on line 104 writes to the correct parameter key. The two paths never intersect.
The attack requires only one real-world treasure secret. An attacker generates a proof binding that secret to a fresh recipient address, calls claim(), and receives 10 ETH. They then regenerate a proof for the same secret bound to a different recipient (a cheap off-chain operation) and call claim() again — the guard still inspects the zero slot, so it passes; the proof still verifies (the circuit only checks set membership and recipient binding); and a second 10 ETH is paid out. The loop continues until claimsCount == MAX_TREASURES.
Read the duplicate-claim flag from the function parameter treasureHash that the ZK proof is actually bound to, and delete the stray _treasureHash immutable so the intent is clear and the typo cannot be reintroduced. With this change, every successful claim() writes claimed[treasureHash] = true on line 104 and every subsequent call observes that same entry on line 88, closing the replay window.
A regression test that submits two proofs for the same treasureHash with different recipients and asserts the second reverts with AlreadyClaimed would have caught this bug at CI time.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.