SNARKeling Treasure Hunt

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

Same treasure can be claimed unlimited times - funds at direct risk of 100% drainage

Root + Impact

Description

The claim() function checks claimed[_treasureHash] to prevent
double-claims, but _treasureHash is an immutable field that is
never initialized in the constructor, so it permanently defaults
to bytes32(0).

Meanwhile, when a treasure IS claimed, it marks claimed[treasureHash]
(the function parameter). These are two different mapping keys — the
check always reads claimed[0x0] which is always false, so the
protection never triggers.

// Line 88 - checks the WRONG variable (always false):
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
// Line 104 - marks the CORRECT variable (never read by check):
_markClaimed(treasureHash);

Risk

Likelihood:

  • Any caller with a valid ZK proof can call claim() repeatedly

  • No special access or conditions needed — exploitable immediately
    on deployment

Impact:

  • Same treasure can be claimed unlimited times with different
    recipient addresses

  • Each claim pays 10 ETH from the contract

  • All 100 ETH can be fully drained in a single transaction sequence

Proof of Concept

function testExploit_FundDrain_SameTreasure10Times() public {
uint256 initialBalance = address(hunt).balance;
assertEq(initialBalance, 100 ether);
address[] memory attackers = new address[](10);
for (uint i = 0; i < 10; i++) {
attackers[i] = address(uint160(0x1000 + i));
vm.deal(attackers[i], 1 ether);
}
for (uint i = 0; i < 10; i++) {
vm.prank(attackers[i]);
hunt.claim(proof, treasureHash, payable(attackers[i]));
assertEq(attackers[i].balance, 1 ether + hunt.REWARD());
}
assertEq(address(hunt).balance, 0);
assertEq(hunt.getClaimsCount(), 10);
}

Recommended Mitigation

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