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.
In `claim()`, the guard uses `claimed[_treasureHash]`, where `_treasureHash` is an immutable state variable that is never initialized to the caller-supplied treasure identifier, while the contract later marks `claimed[treasureHash] = true` using the function argument instead. As a result, the duplicate-claim check and the state update are performed against different keys, which means a previously claimed treasure is not actually blocked from being claimed again with the same valid proof and `treasureHash`. This breaks a core invariant of the protocol described in the README, namely, that each treasure can only be redeemed once, and allows one valid treasure/proof pair to be reused to drain rewards repeatedly until either the `MAX_TREASURES` cap or the contract balance is exhausted.
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.