SNARKeling Treasure Hunt

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

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

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)).

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!