SNARKeling Treasure Hunt

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

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

Author Revealed upon completion

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

Support

FAQs

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

Give us feedback!