SNARKeling Treasure Hunt

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

Uninitialized `_treasureHash` immutable causes claim() to check wrong slot, allowing unlimited claims with a single valid proof

Author Revealed upon completion

Description

  • A user who finds a valid treasure calls claim(proof, treasureHash, recipient), which verifies a ZK proof and marks the treasure as claimed so it cannot be paid out twice.

  • claim() reads the already-claimed flag from the uninitialized immutable _treasureHash (which is always bytes32(0)) instead of the treasureHash parameter. _markClaimed writes to claimed[treasureHash], so the guard never fires. A single valid proof can be replayed up to MAX_TREASURES times with different recipients, draining the entire reward pool.

// contracts/src/TreasureHunt.sol
bytes32 private immutable _treasureHash; // @> declared but never assigned -> always 0x0
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
...
@> if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash); // checks claimed[0x0], always false
...
bool ok = verifier.verify(proof, publicInputs);
if (!ok) revert InvalidProof();
_incrementClaimsCount();
@> _markClaimed(treasureHash); // writes the *correct* key, but the guard above never reads it
(bool sent, ) = recipient.call{value: REWARD}("");
}

Risk

Likelihood:

  • Occurs on every claim attempt because the bug is in the hot path of claim(); any holder of a valid secret can exploit it without special conditions.

  • A single treasure secret found in the real world is sufficient — the attacker only needs to regenerate proofs locally with different recipient public inputs.

Impact:

  • Direct theft of the entire contract balance (100 ETH with the default funding of 10 treasures × 10 ETH).

  • Honest finders of the remaining treasures revert with NotEnoughFunds / AllTreasuresClaimed, so the hunt's economic model is broken.

Proof of Concept

The bug is directly observable from the storage layout: _treasureHash is declared immutable on line 35 but never written inside the constructor (lines 67–75). Solidity initializes unassigned immutables to the zero value, so every read of _treasureHash yields bytes32(0). Line 88's duplicate-claim guard therefore always evaluates claimed[bytes32(0)], which is never written because _markClaimed(treasureHash) on line 104 writes to the correct parameter key. The two paths never intersect.

The attack requires only one real-world treasure secret. An attacker generates a proof binding that secret to a fresh recipient address, calls claim(), and receives 10 ETH. They then regenerate a proof for the same secret bound to a different recipient (a cheap off-chain operation) and call claim() again — the guard still inspects the zero slot, so it passes; the proof still verifies (the circuit only checks set membership and recipient binding); and a second 10 ETH is paid out. The loop continues until claimsCount == MAX_TREASURES.

// Foundry sketch — shows the same treasureHash claimed twice with different recipients
function test_sameTreasureClaimedMultipleTimes() public {
(bytes memory proofA, bytes32 hash) = _makeProof(secret, alice);
hunt.claim(proofA, hash, payable(alice));
assertEq(alice.balance, 10 ether);
assertTrue(hunt.claimed(hash)); // mapping says "claimed"
// Same secret, different recipient, new proof.
(bytes memory proofB,) = _makeProof(secret, bob);
hunt.claim(proofB, hash, payable(bob)); // should revert AlreadyClaimed, but succeeds
assertEq(bob.balance, 10 ether);
// Repeatable up to MAX_TREASURES = 10 total calls, draining 100 ETH with one secret.
}

Recommended Mitigation

Read the duplicate-claim flag from the function parameter treasureHash that the ZK proof is actually bound to, and delete the stray _treasureHash immutable so the intent is clear and the typo cannot be reintroduced. With this change, every successful claim() writes claimed[treasureHash] = true on line 104 and every subsequent call observes that same entry on line 88, closing the replay window.

- bytes32 private immutable _treasureHash;
-
...
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);

A regression test that submits two proofs for the same treasureHash with different recipients and asserts the second reverts with AlreadyClaimed would have caught this bug at CI time.

Support

FAQs

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

Give us feedback!