SNARKeling Treasure Hunt

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

H-01. Double-claim guard reads an uninitialised immutable, allowing unlimited proof replay that drains the entire prize pool

Author Revealed upon completion

Description

  • claim() must mark claimed[treasureHash] = true after a successful proof so the same treasureHash cannot be redeemed twice.

  • The guard reads the contract's immutable _treasureHash, which is never assigned in the constructor and is therefore permanently bytes32(0). _markClaimed writes under the parameter key, so claimed[0x0] is never set and the check is a permanent no-op. A single valid proof can be replayed until claimsCount == MAX_TREASURES, paying 10 × REWARD = 100 ETH.

bytes32 private immutable _treasureHash; // @> never initialised -> always bytes32(0)
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
...
@> if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash); // reads zeroed immutable, not the parameter
...
_markClaimed(treasureHash); // writes claimed[treasureHash], leaving claimed[0x0] untouched
}

Risk

Likelihood:

  • Triggers on the happy path — any valid proof submitted (or observed in the mempool) is re-broadcastable as-is, with no privileged role required.

  • The project's own testClaimDoubleSpendReverts has vm.expectRevert() commented out and still passes, confirming the replay.

Impact:

  • Up to 100 ETH drained to the single recipient bound inside the replayed proof — a 10× payout escalation.

  • claimsCount saturates at MAX_TREASURES, bricking the hunt for the 9 remaining finders with AllTreasuresClaimed.

Proof of Concept

The test submits the same (proof, treasureHash, recipient) tuple 10 times. Each iteration passes the guard because it checks claimed[bytes32(0)] instead of claimed[treasureHash], so the proof is accepted over and over until MAX_TREASURES is exhausted — draining the pool to the proof's recipient and permanently blocking every other finder.

function test_POC_SingleProofDrainsEntirePool() public {
(bytes memory proof, bytes32 treasureHash, address payable recipient) = _loadFixture();
for (uint256 i = 0; i < hunt.MAX_TREASURES(); i++) {
vm.prank(attacker);
hunt.claim(proof, treasureHash, recipient); // same calldata replayed 10x
}
assertEq(address(hunt).balance, 0);
assertEq(recipient.balance, 10 * hunt.REWARD()); // 100 ETH to one address
vm.prank(participant);
vm.expectRevert(TreasureHunt.AllTreasuresClaimed.selector); // hunt is now bricked
hunt.claim(proof, treasureHash, recipient);
}

Recommended Mitigation

The root cause is a single-identifier typo compounded by a dead storage slot that made the typo type-check. The fix has two parts — the guard must read the caller-supplied parameter, and the unused immutable must be removed so a future refactor cannot silently reintroduce the same bug. _markClaimed already writes under the parameter key, so no storage migration or redeploy sequencing is needed beyond pushing the patched bytecode.

1. Delete the uninitialised immutable. It is never assigned in the constructor and never read anywhere except in the broken guard, so removing it eliminates the only symbol a future author could mistakenly reach for:

// ----- immutable config -----
IVerifier private verifier;
address private immutable owner;
- bytes32 private immutable _treasureHash;

2. Check the function parameter in the replay guard. This realigns the read with the write on line 104, so every accepted proof consumes its own slot in the claimed mapping and a replay of the same treasureHash reverts with AlreadyClaimed(treasureHash) on the second attempt:

function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
if (paused) revert ContractPaused();
if (address(this).balance < REWARD) revert NotEnoughFunds();
if (recipient == address(0) || recipient == address(this) || recipient == owner || recipient == msg.sender) revert InvalidRecipient();
if (claimsCount >= MAX_TREASURES) revert AllTreasuresClaimed();
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
if (msg.sender == owner) revert OwnerCannotClaim();

Post-fix behaviour. The PoC above reverts on its second iteration with AlreadyClaimed(treasureHash); the contract retains the other 90 ETH; each of the remaining nine treasures stays claimable exactly once by its legitimate finder; and withdraw() can be reached honestly once all ten slots are consumed. The existing testClaimDoubleSpendReverts should also be updated — its vm.expectRevert() is currently commented out, so it silently passes under the bug and will continue to pass under the fix unless the assertion is restored to vm.expectRevert(abi.encodeWithSelector(TreasureHunt.AlreadyClaimed.selector, treasureHash)).

Support

FAQs

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

Give us feedback!