SNARKeling Treasure Hunt

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

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

Author Revealed upon completion

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);

Support

FAQs

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

Give us feedback!