SNARKeling Treasure Hunt

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

One proof and hash can consume the full ten-claim quota

Root + Impact

Description

  • Normal behavior: Each of ten distinct treasures should be able to pay out once under fair rules; claimsCount should reflect distinct successful hunts.

  • Problem: Combining the broken replay guard (snarkeling-1) with claimsCount capped at MAX_TREASURES, a participant can call claim ten times with the same treasureHash and proof, consuming the full hunt quota and blocking nine other treasures from paying out under normal fairness assumptions.

// @> cap increments every successful claim — same hash can advance count repeatedly
if (claimsCount >= MAX_TREASURES) revert AllTreasuresClaimed();
// ... snarkeling-1: replay guard reads claimed[_treasureHash] while _markClaimed uses treasureHash ...
_incrementClaimsCount();

Risk

Likelihood:

  • Same exploit path as snarkeling-1 whenever a valid proof exists.

  • A single automated loop drains quota in one session.

Impact:

  • One treasure id consumes all ten slots; others never pay under intended rules.

  • High fairness impact for multi-participant hunts.

Proof of Concept

Explanation: claimsCount increments on every successful claim, but the replay guard does not persist claimed[treasureHash] correctly (snarkeling-1). One (proof, treasureHash) pair can therefore consume all 10 slots, blocking nine other treasure hashes from ever paying under a fair per-treasure policy.

Supporting code — commands (after circuits/scripts/build.sh):

forge test --match-test testPoC_RepeatedClaimDrainsAllRewards -vv

Supporting code — full test (same as snarkeling-1; demonstrates quota burn):

// contracts/test/TreasureHuntPoC.t.sol — testPoC_RepeatedClaimDrainsAllRewards
function testPoC_RepeatedClaimDrainsAllRewards() public {
(bytes memory proof, bytes32 treasureHash, address payable recipient) = _fixture();
uint256 recipientBefore = recipient.balance;
vm.startPrank(PARTICIPANT);
for (uint256 i = 0; i < 10; i++) {
hunt.claim(proof, treasureHash, recipient);
}
vm.stopPrank();
assertEq(hunt.claimsCount(), 10, "single hash consumed full quota");
assertEq(address(hunt).balance, 0);
assertEq(recipient.balance, recipientBefore + (10 * hunt.REWARD()));
}

Expected result: test passes; claimsCount == 10 with identical (proof, treasureHash) every iteration.

Recommended Mitigation

Explanation: Fix the replay guard as in snarkeling-1 so claimed[treasureHash] is set for the hash used in claim. After that, optionally enforce that claimsCount only advances when treasureHash was not previously claimed, so one hash cannot consume the entire quota. That restores fair participation across ten distinct treasures.

-// see snarkeling-1 — align claimed[treasureHash] guard with _markClaimed
+// optionally require claimsCount to increment only for distinct treasureHash after guard is correct
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!