SNARKeling Treasure Hunt

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

Wrong claimed-storage key allows repeated payout for same treasure

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: Each treasureHash should be claimable at most once. The claimed mapping should block a second successful claim for the same hash after a valid proof pays out once.

  • Problem: The replay check reads claimed[_treasureHash] where _treasureHash is an immutable that is never set in the constructor, while _markClaimed writes claimed[treasureHash]. The guard slot never tracks real claims, so the same valid proof and hash can pay out repeatedly until claimsCount reaches MAX_TREASURES.

// @> immutable never assigned — guard reads a fixed default slot for normal user hashes
bytes32 private immutable _treasureHash;
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
// ...
// @> replay guard uses _treasureHash (wrong key), not treasureHash
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
// ...
_markClaimed(treasureHash); // @> write uses treasureHash (different key from guard)
}

Risk

Likelihood:

  • A participant with one valid proof and matching public inputs calls claim in a loop until the contract balance is exhausted or claimsCount hits the cap.

  • The mistaken guard slot stays false for user-supplied hashes, so every repeat passes the replay check after proof verification succeeds.

Impact:

  • Up to MAX_TREASURES * REWARD ETH can be sent to the same recipient using one proof and one hash.

  • Honest hunters lose intended per-treasure fairness because one hash can consume the full claim budget.

Proof of Concept

The replay guard reads claimed[_treasureHash] (immutable never set) while _markClaimed sets claimed[treasureHash], so the guard slot never flips for a real treasureHash. The Foundry test below loads a valid fixture proof once, then calls claim ten times with the same proof, treasureHash, and recipient. Prerequisites: from the contest repo root, run circuits/scripts/build.sh once so contracts/test/fixtures/proof.bin and public_inputs.json exist. How to run: forge test --match-test testPoC_RepeatedClaimDrainsAllRewards -vv. Expected result: test passes; claimsCount reaches 10, contract balance goes to zero, recipient receives 10 * REWARD.

// contracts/test/TreasureHuntPoC.t.sol
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);
assertEq(address(hunt).balance, 0);
assertEq(recipient.balance, recipientBefore + (10 * hunt.REWARD()));
}

Recommended Mitigation

- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
- bytes32 private immutable _treasureHash;
+ // remove if unused, or set in constructor when a single global hash is intended

Support

FAQs

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

Give us feedback!