SNARKeling Treasure Hunt

First Flight #59
Beginner FriendlyGameFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Wrong storage key used in duplicate-claim check allows the same treasure to be claimed multiple times

Root + Impact

The claim() function is designed so that each unique treasureHash can only be claimed once. After a successful claim, the hash is recorded in the claimed mapping and any future attempt to claim the same hash should revert.

The duplicate-claim guard reads from claimed[_treasureHash] — the private immutable — instead of claimed[treasureHash] — the caller-supplied argument. Because _treasureHash is never assigned in the constructor, it permanently holds bytes32(0). The guard therefore only ever blocks claims where the public input happens to be zero, leaving every real treasure hash unprotected. _markClaimed correctly writes to claimed[treasureHash], so the check and the write operate on permanently different storage keys.

function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
...
// @> reads from the uninitialized immutable, not the caller-supplied treasureHash
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
...
_incrementClaimsCount();
// @> writes to the correct key — check and write are permanently mismatched
_markClaimed(treasureHash);
...
}

function _markClaimed(bytes32 treasureHash) internal {
// @> writes to claimed[treasureHash] — correct key, but the guard above never reads this
claimed[treasureHash] = true;
}


Risk

Likelihood: High

  • Any caller who obtains a valid ZK proof for a treasure can immediately resubmit the same proof and treasureHash in a second transaction, since the guard never trips for a non-zero hash.

  • The only natural rate-limiter is claimsCount >= MAX_TREASURES, meaning a single proof-holder can drain up to MAX_TREASURES × REWARD (100 ETH) before the contract halts.

Impact: Critical

  • A single valid proof can be replayed up to 10 times, draining the entire 100 ETH contract balance to a single recipient.

  • All other treasure hunters are permanently locked out — once claimsCount reaches MAX_TREASURES, AllTreasuresClaimed reverts every subsequent call, even for treasures that were never legitimately found.

Proof of Concept

// Attacker obtains one valid proof for treasureHash = 0xABC...
// They call claim() 10 times with the same proof and hash.
// Each call passes the guard (claimed[bytes32(0)] == false always),
// increments claimsCount, and transfers 10 ETH.
// After 10 calls: attacker holds 100 ETH, contract is drained,
// claimsCount == MAX_TREASURES, hunt is permanently over.
for (uint i = 0; i < 10; i++) {
treasureHunt.claim(validProof, 0xABC..., recipient);
// each call succeeds — claimed[bytes32(0)] is never set to true
}

Recommended Mitigation

- function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
if (paused) revert ContractPaused();
if (address(this).balance < REWARD) revert NotEnoughFunds();
if (recipient == address(0) || recipient == address(this) || recipient == owner || recipient == msg.sender) revert InvalidRecipient();
if (claimsCount >= MAX_TREASURES) revert AllTreasuresClaimed();
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
if (msg.sender == owner) revert OwnerCannotClaim();
...
}
+
Change the duplicate-claim guard to read from the caller-supplied treasureHash argument, matching the key that _markClaimed writes to. The private immutable _treasureHash serves no purpose in the current codebase and should be removed entirely.
Updates

Lead Judging Commences

s3mvl4d Lead Judge 18 days ago
Submission Judgement Published
Validated
Assigned finding tags:

broken double-claim protection

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.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!