ALLOWED_TREASURE_HASHES allows a single proof to claim two rewards, permanently locking one treasure slotThe circuit defines 10 allowed treasure hashes baked into the ALLOWED_TREASURE_HASHES constant. The is_allowed() helper checks membership only, not uniqueness, meaning only 9 unique treasures exist.
Indices 8 and 9 are identical, so the holder of the proof for the hash at index 8 can submit two valid claims — one per unique treasureHash entry in claimed mapping — collecting 20 ETH instead of 10 ETH. The 10th unique treasure slot is unreachable by any other participant, meaning one reward is either stolen or permanently locked depending on execution order. This is compounded by [H-1] — with replay protection already broken, the same proof can be submitted MAX_TREASURES times draining the full contract before the duplicate even matters.
Likelihood:
Any holder of the proof corresponding to the duplicate hash at index 8 will be able to submit two valid claims, as both index 8 and index 9 pass the is_allowed() check with the same input.
The duplicate exists statically in the compiled circuit constant, making this exploitable on every deployment without any special precondition.
Impact:
One proof holder can claim 20 ETH instead of 10 ETH, doubling their reward at the protocol's expense.
One treasure slot is permanently unclaimable by any legitimate unique participant, contradicting the circuit comment: "This allows the prover to demonstrate knowledge of a valid treasure without revealing which one it is" — only 9 distinct secrets exist, not 10.
Replace the duplicate hash at index 9 with a unique valid pedersen hash corresponding to a distinct treasure secret:
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 issue stems from a mismatch between the circuit and the contract’s economic assumptions: the Solidity contract is configured for `MAX_TREASURES = 10` and only allows the owner to call `withdraw()` once `claimsCount >= MAX_TREASURES`, while the Noir circuit’s baked-in `ALLOWED_TREASURE_HASHES` array does not actually contain ten distinct treasures because one hash is duplicated and another expected hash is missing. As a result, under the intended one-claim-per-treasure design described in the README, there are only nine uniquely claimable treasures even though the system is funded and accounted as if ten rewards can be legitimately redeemed. That creates two linked consequences from the same root cause: first, one treasure is effectively unclaimable because no valid proof can ever be generated for the missing allowed hash, and second, the normal “hunt over” withdrawal path becomes bricked because honest participants can never reach ten legitimate unique claims, leaving the post-hunt fund recovery logic via `withdraw` function permanently unreachable. The owner can still intervene through the emergency path.
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.