SNARKeling Treasure Hunt

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

Wrong variable in claim() double-spend check lets attacker drain all funds with one proof

claim() checks _treasureHash (uninitialized immutable) instead of treasureHash (parameter), breaking the double-claim guard and allowing full fund drain

Description

The claim() function is supposed to prevent the same treasure from being claimed twice. But at line 88, it checks the wrong variable:

// TreasureHunt.sol line 88
@> if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);

_treasureHash here is the immutable storage variable declared on line 35, which is never assigned anywhere in the constructor. So it defaults to bytes32(0). The function then marks the correct parameter on line 104 via _markClaimed(treasureHash), but since the check and the mark operate on different keys, the guard never actually blocks a repeat claim.

Basically: the check reads claimed[bytes32(0)] (always false), while the mark writes claimed[actualHash] (irrelevant to the check). So any valid proof can be replayed over and over.

The only thing that stops it eventually is claimsCount >= MAX_TREASURES on line 87, but by that point an attacker has already called claim() 10 times with one proof and drained all 100 ETH.

Risk

Likelihood: This will happen every time someone claims a treasure. There's no special condition needed — the bug is in the normal execution path. Any participant who has one valid proof can just call claim() in a loop.

Impact: Complete loss of contract funds (100 ETH). A single participant with one valid ZK proof drains the entire reward pool, leaving nothing for the other 9 treasure finders.

Proof of Concept

function testDoubleClaim() public {
(
bytes memory proof,
bytes32 treasureHash,
address payable recipient
) = _loadFixture();
uint256 balBefore = address(hunt).balance; // 100 ETH
// claim the same treasure 10 times with the same proof
for (uint256 i = 0; i < 10; i++) {
hunt.claim(proof, treasureHash, recipient);
}
// all 100 ETH gone to one recipient
assertEq(address(hunt).balance, 0);
assertEq(recipient.balance, balBefore);
}

Note: the existing test testClaimDoubleSpendReverts in the repo has vm.expectRevert() commented out on line 144, which masks this bug — the second claim silently succeeds.

Recommended Mitigation

- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);

Also consider removing the unused _treasureHash immutable variable entirely to avoid confusion.

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!