SNARKeling Treasure Hunt

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

claim() checks wrong variable — uninitialised immutable `_treasureHash` instead of parameter `treasureHash` — enabling full treasury drain via proof replay

Author Revealed upon completion

Root + Impact

Description

Normal behaviour: claim() should pay out a REWARD of 10 ETH to recipient exactly once per unique treasureHash, enforced by the claimed mapping. After MAX_TREASURES = 10 distinct claims the hunt is over.

Specific issue: the anti-double-claim guard reads the contract-level immutable _treasureHash, which is declared but never initialised (line 35), instead of the user-supplied function parameter treasureHash. The immutable resolves to bytes32(0) forever, so claimed[_treasureHash] always reads claimed[0x0] — a slot that is never written by any realistic claim. The guard never fires, and the same (proof, treasureHash, recipient) tuple can be replayed up to 10 times, draining the full 100 ETH treasury with a single valid ZK proof.

// contracts/src/TreasureHunt.sol
bytes32 private immutable _treasureHash; // @> declared but never initialised -> always 0x0
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
// ...
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash); // @> reads wrong variable (immutable instead of parameter)
// ...
_markClaimed(treasureHash); // writes the CORRECT key - creating read/write asymmetry
// ...
(bool sent, ) = recipient.call{value: REWARD}("");
}

Risk

Likelihood:

  • The claimer controls all three inputs (proof, treasureHash, recipient) and can simply re-send the same calldata — no additional skill, capital, or timing required.

  • Anyone observing a successful claim tx on-chain (or in the mempool) can copy the exact calldata and replay it; they only need a non-owner / non-claimer recipient, which the proof already binds to.

Impact:

  • Full loss of the 100 ETH treasury (10 replays × 10 ETH reward) off a single valid proof.

  • Permanent DoS of the remaining 9 treasures: claimsCount saturates at MAX_TREASURES, so legitimate treasure finders can never claim again — even if the owner re-funds the contract.

Proof of Concept

Self-contained Foundry test using a stubbed MockAcceptVerifier (returns true for any proof — models the attacker having already obtained one valid proof; identical dynamics with the real Barretenberg verifier):

// contracts/test/DoubleClaimExploit.t.sol
function test_doubleClaimDrainsContract() public {
bytes memory anyProof = hex"deadbeef";
bytes32 oneTreasureHash = keccak256("treasure_1");
assertEq(address(hunt).balance, 100 ether);
assertEq(recipient.balance, 0);
vm.startPrank(finder);
for (uint256 i = 0; i < 10; i++) {
hunt.claim(anyProof, oneTreasureHash, recipient); // same tuple replayed 10x
}
vm.stopPrank();
assertEq(address(hunt).balance, 0); // treasury emptied
assertEq(recipient.balance, 100 ether); // full 100 ETH drained
assertEq(hunt.claimsCount(), 10); // hunt permanently ended
}

Output:

[PASS] test_doubleClaimDrainsContract() (gas: 334411)

Recommended Mitigation

- bytes32 private immutable _treasureHash;
-
// ...
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
// ...
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
// ...
}

Remove the unused immutable entirely (it serves no purpose) and make the guard read the function parameter. After the fix, a second call with the same treasureHash reverts with AlreadyClaimed(treasureHash) as intended.

Support

FAQs

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

Give us feedback!