SNARKeling Treasure Hunt

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

Uninitialized state variable, Typo in 'Claim' Function, allowing infinite draining via double claiming

Root + Impact

Description

Smart contract works by verifing that specific treasure hash has not been claimed yet before distributing the rewards to the recipient.
The issue is a typrographical error in the 'Claim' function duplicate-claim guard.
The duplicate-claim guard read from the uinitialized immutable state variable '_treasureHash', which always evaluates to bytes32(0), instead if the function parameter 'treasureHash'. Because of claimed[bytes32(0)] is always false, the guard never triggers and the same proof can be submitted an unlimited number of times.

if (claimsCount >= MAX_TREASURES) revert AllTreasuresClaimed();
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash); // _treasureHash == bytes32(0) always
if (msg.sender == owner) revert OwnerCannotClaim();

Risk

Likelihood:

  • Any attacker who observe a single successful 'claim' transaction on chain can immediately copies the proof and treasure hash and resubmit them repeatedly. It does not require no special knowledge or tooling beyond a basic script.

  • 'claimsCount' is incremented each time, the attacker can loop until 'claimsCount >= MAX_TREASURES' is hit, draining the full contract balance in one automated sequence of transactions.

Impact:

  • Total loss of all ETH held by the 'TreasureHunt' contract (10 treasures x 10 ETH each).

  • Complete breakdown of hunt mechanics. A single stolen or observed proof replaces all 10 legitimate treasure finds and claims every reward.

Proof of Concept

The root cause of that '_treasuereHash' is an uninitailized immutable state variable that permanently holds bytes32(0). This mean claimed[bytes32(0)], which is always false. the guard therefore never reverts, no matter how many times the same 'treasureHash' is submitted.

The attack flow is straightforward:

  • A legitimate user submits a valid 'claim' transaction. This is the only hard step for finding the treasure.

  • Once finding the treasure the attacker observes that transaction on-chain or in the mempool and copies the proof and treasure hash.

  • The attacker call claim() repeatedly with those copied hash. Each call passes the guard because claimed[Bytes32(0)] never become true while the 'claimsCount' increments towards 'MAX_TREASURE'.

  • Once 'claimsCount >= MAX_TREASURES', the loop ends but by then attacker has drained the full contract balance.

function test_InfiniteDrainViaDoubleClaim() public {
// 1. Legitimate user submits a valid claim once
vm.prank(user);
treasureHunt.claim(validProof, validTreasureHash, userRecipient);
// 2. Attacker copies the proof & hash from the mempool/chain and resubmits
vm.prank(attacker);
treasureHunt.claim(validProof, validTreasureHash, attackerRecipient);
// 3. Attacker keeps repeating claimed[bytes32(0)] is always false,
// so the guard never reverts regardless of how many times this runs.
vm.prank(attacker);
treasureHunt.claim(validProof, validTreasureHash, attackerRecipient);
// Contract balance is now fully drained.
assertEq(address(treasureHunt).balance, 0);
}

Recommended Mitigation

Replace the unintialized state variable '_treasureHash' with the function parameter 'treasureHash' in the duplicate-claim guard.

- remove this code
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ add this code
if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
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!