SNARKeling Treasure Hunt

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

### [H-2] Duplicate hash in `ALLOWED_TREASURE_HASHES` allows a single proof to claim two rewards, permanently locking one treasure slot

Author Revealed upon completion

Duplicate hash in ALLOWED_TREASURE_HASHES allows a single proof to claim two rewards, permanently locking one treasure slot

Description

  • The circuit defines 10 allowed treasure hashes baked into the ALLOWED_TREASURE_HASHES constant. The is_allowed() helper checks membership only, not uniqueness, meaning only 9 unique treasures exist.

  • Indices 8 and 9 are identical, so the holder of the proof for the hash at index 8 can submit two valid claims — one per unique treasureHash entry in claimed mapping — collecting 20 ETH instead of 10 ETH. The 10th unique treasure slot is unreachable by any other participant, meaning one reward is either stolen or permanently locked depending on execution order. This is compounded by [H-1] — with replay protection already broken, the same proof can be submitted MAX_TREASURES times draining the full contract before the duplicate even matters.

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
...
-961435057317293580094826482786572873533235701183329831124091847635547871092, // index 8
-961435057317293580094826482786572873533235701183329831124091847635547871092 // index 9 — duplicate
];
fn is_allowed(hash: Field) -> bool {
let mut ok = false;
for i in 0..10 {
if hash == ALLOWED_TREASURE_HASHES[i] {
ok = true; // @> membership check only, no uniqueness enforced
}
}
ok // @> returns true for both index 8 and 9 with the same input
}

Risk

Likelihood:

  • Any holder of the proof corresponding to the duplicate hash at index 8 will be able to submit two valid claims, as both index 8 and index 9 pass the is_allowed() check with the same input.

  • The duplicate exists statically in the compiled circuit constant, making this exploitable on every deployment without any special precondition.

Impact:

  • One proof holder can claim 20 ETH instead of 10 ETH, doubling their reward at the protocol's expense.

  • One treasure slot is permanently unclaimable by any legitimate unique participant, contradicting the circuit comment: "This allows the prover to demonstrate knowledge of a valid treasure without revealing which one it is" — only 9 distinct secrets exist, not 10.

Proof of Concept

function test_duplicateHashClaimsTwice() public {
(bytes memory proof, bytes32 treasureHash, address payable recipient) = _loadFixture();
// Confirm the fixture proof corresponds to the duplicate hash (index 8 and 9)
// by checking it passes verification twice with the same treasureHash
address caller = makeAddr("caller");
uint256 recipientBalanceBefore = recipient.balance;
// First claim — valid, expected
vm.prank(caller);
hunt.claim(proof, treasureHash, recipient);
assertEq(recipient.balance, recipientBalanceBefore + 10 ether);
// Second claim — same proof, same hash, should revert but doesn't
// `claimed[treasureHash]` is set, but `claimed[_treasureHash]` check never fires (H-1)
// Even with H-1 fixed, duplicate in circuit means two valid proofs exist for same secret
vm.prank(caller);
hunt.claim(proof, treasureHash, recipient);
assertEq(recipient.balance, recipientBalanceBefore + 20 ether);
assertEq(hunt.claimsCount(), 2);
}

Recommended Mitigation

Replace the duplicate hash at index 9 with a unique valid pedersen hash corresponding to a distinct treasure secret:

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
...
-961435057317293580094826482786572873533235701183329831124091847635547871092,
- -961435057317293580094826482786572873533235701183329831124091847635547871092
+ <unique_tenth_pedersen_hash>
];

Support

FAQs

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

Give us feedback!