SNARKeling Treasure Hunt

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

ZK Proof Replay Enables Unlimited ETH Drain via Missing Treasure Nullifier

Author Revealed upon completion

Root + Impact

Description

  • The TreasureHunt protocol is designed so that each treasure hash can only be claimed once by submitting a valid ZK proof. Once claimed, the system should permanently prevent reuse of the same proof/treasure to protect ETH rewards.

  • The contract does not cryptographically bind proof uniqueness to on-chain state. If the ZK verifier accepts the same valid proof multiple times (which is normal behavior for most SNARK verifiers), the contract has no on-chain replay protection, allowing repeated claims for the same treasure.

// Root cause: no on-chain nullifier / proof replay protection
// @> proof verification result is trusted without state-binding uniqueness
function claimTreasure(bytes calldata proof, uint256 treasureHash) external {
require(verifier.verify(proof, treasureHash), "INVALID_PROOF");
// @> missing: usedProof[hash] or nullifier check
payable(msg.sender).transfer(REWARD);
}

Risk

Likelihood:

  • Occurs whenever a valid proof is generated once (expected protocol usage).

Replays can be executed immediately in the same block or across blocks.

Impact:

  • Complete ETH drain of TreasureHunt contract.

  • One treasure → unlimited rewards.

  • Breaks the core invariant: one treasure = one reward.

Proof of Concept

  1. Attacker legitimately finds one treasure.

Generates one valid ZK proof off-chain.
3. Calls claimTreasure(proof, treasureHash) → receives REWARD.
4. Repeats the exact same transaction:

  • Same proof

  • Same treasureHash

  1. Contract pays again.

  2. Loop until contract balance is zero.

    // Attacker script
    for (uint i = 0; i < 100; i++) {
    treasureHunt.claimTreasure(validProof, treasureHash);
    }

Recommended Mitigation

- remove this code
+ add this code
mapping(uint256 => bool) public treasureClaimed;
function claimTreasure(bytes calldata proof, uint256 treasureHash) external {
require(!treasureClaimed[treasureHash], "TREASURE_ALREADY_CLAIMED");
require(verifier.verify(proof, treasureHash), "INVALID_PROOF");
treasureClaimed[treasureHash] = true;
payable(msg.sender).transfer(REWARD);
}
mapping(bytes32 => bool) public usedNullifiers;

Support

FAQs

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

Give us feedback!