TreasureHunt declares bytes32 private immutable _treasureHash; at line 35 but the constructor (lines 64–72) never initializes it. The replay guard in claim() reads claimed[_treasureHash] (an uninitialized, always-zero key) while _markClaimed(treasureHash) writes to the user-supplied treasureHash key. Because the read key and the write key differ, the replay guard never triggers for legitimate claims. One valid (proof, treasureHash, recipient) tuple can therefore consume every REWARD slot and drain the full 100 ETH hunt funding.
Slither independently flags this on the scoped file:
Likelihood: High.
The bug is reachable on the external claim() entrypoint, requires no privileged role, survives the nonReentrant guard, and triggers on every successful legitimate claim.
Any participant who finds any treasure is one replayed call away from draining the contract; the bug fires whenever the replay guard claimed[_treasureHash] read-key diverges from the _markClaimed(treasureHash) write-key — which is always, because _treasureHash is never assigned.
Impact: High.
A single valid (proof, treasureHash, recipient) tuple — i.e. any legitimate claim of any treasure — can be replayed up to MAX_TREASURES times. claimsCount increments each time and REWARD is transferred each time.
The entire 100 ETH (10 treasures × 10 ETH) can be drained to a single recipient using one legitimate discovery, preventing every other participant from ever claiming a reward.
The Foundry test below models any accepted (proof, public-inputs) tuple with a minimal AlwaysValidVerifier. The replay bug is independent of proof validity because it lives in the bookkeeping that runs after verifier.verify(...) returns true. File: contracts/test/hunter_pocs/DoubleClaimPoC.t.sol.
Command + result:
Replace the uninitialized-immutable guard with a check against the user-supplied treasureHash, and delete the unused _treasureHash field:
Add a regression test that calls claim() twice with the same (proof, treasureHash, recipient) and expects the second call to revert with AlreadyClaimed.
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.