SNARKeling Treasure Hunt

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

No proof hash tracking allows theoretical proof replay attacks

Author Revealed upon completion

Root + Impact

Description

The claim() function in contracts/src/TreasureHunt.sol lines 83-112
has no on-chain tracking of which proofs have been used. While the ZK
circuit binds proofs to recipients at the cryptographic level, the same
proof bytes could theoretically be submitted multiple times since there
is no usedProofs mapping to reject replayed proofs.

// No proof tracking exists in claim():
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external {
// proof is verified but never recorded as used
// same proof bytes can be submitted again
}

Risk

Likelihood:

  • Requires a valid ZK proof to attempt replay

  • ZK verification already rejects proofs bound to a different recipient

Impact:

  • Same proof could be submitted multiple times as defense-in-depth is missing

  • Adding proof tracking would close this theoretical vector completely

  • Low isolated impact but recommended as hardening measure

Proof of Concept

This test shows the contract has no mechanism to reject a previously
used proof. Without a usedProofs mapping, the same proof bytes can
be passed to claim() again with no on-chain rejection at the proof
level — only ZK verification and the double-claim check act as barriers.

function testExploit_ProofReplay() public {
bytes memory proof = validProof;
bytes32 treasureHash = validHash;
address payable recipient1 = payable(attacker1);
address payable recipient2 = payable(attacker2);
// Claim 1: succeeds with recipient1
vm.prank(attacker1);
hunt.claim(proof, treasureHash, recipient1);
// Claim 2: same proof bytes, different recipient
// Rejected by ZK verification since proof is bound to recipient1
// But NOT rejected by any usedProofs check — none exists
vm.prank(attacker2);
vm.expectRevert();
hunt.claim(proof, treasureHash, recipient2);
}

Recommended Mitigation

Add a usedProofs mapping to track and reject replayed proof bytes.
This provides defense-in-depth independent of ZK verification logic.

+ mapping(bytes32 => bool) private usedProofs;
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external {
+ bytes32 proofHash = keccak256(proof);
+ if (usedProofs[proofHash]) revert ProofAlreadyUsed();
+ usedProofs[proofHash] = true;
// ... rest of claim logic
}

Support

FAQs

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

Give us feedback!