SNARKeling Treasure Hunt

First Flight #59
Beginner FriendlyGameFiFoundry
100 EXP
Submission Details
Impact: high
Likelihood: high

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

Author Revealed upon completion

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.

Support

FAQs

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

Give us feedback!