SNARKeling Treasure Hunt

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

Circuit ALLOWED_TREASURE_HASHES contains only 9 unique hashes (missing 9th), bricking the honest withdraw() path

Author Revealed upon completion

Description

Normal behavior: The Noir circuit at circuits/src/main.nr embeds a 10-element ALLOWED_TREASURE_HASHES array, one hash per treasure. Each entry must be a distinct Pedersen hash so that 10 separate treasure preimages can produce distinct valid proofs, and the Solidity withdraw() function (gated on claimsCount >= MAX_TREASURES = 10) becomes reachable on the honest happy path after all treasures are claimed.

Specific issue: The ALLOWED_TREASURE_HASHES array at circuits/src/main.nr:55-66 contains only 9 unique values — indices 8 and 9 hold the same hash (-961435057317293580094826482786572873533235701183329831124091847635547871092). Cross-referencing the Deploy script's documented intended 10-hash set at contracts/scripts/Deploy.s.sol:17-26 confirms that the 9th intended hash (-4417726114039171734934559783368726413190541565291523767661452385022043124552) is absent from the circuit. The 10th hash appears twice.

// circuits/src/main.nr
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
-961435057317293580094826482786572873533235701183329831124091847635547871092, //@> duplicate of next
-961435057317293580094826482786572873533235701183329831124091847635547871092 //@> 9th intended hash missing
];

The accompanying test circuits/src/tests.nr::test_treasure_hunt_all_treasures_success passes only because it uses treasures = [1, 2, 3, 4, 5, 6, 7, 8, 10, 10] — treasure #9 is silently replaced by a second instance of #10, which is exactly the bug.

Risk

Likelihood: HIGH

Reason 1: The bug is present in the current code; no conditional trigger required. Any deployment that uses the current circuit artifacts has this issue.

Reason 2: The intended 9th treasure finder cannot generate a valid proof — their treasure's hash is not in ALLOWED_TREASURE_HASHES, so is_allowed(treasure_hash) returns false and the circuit's first assertion fails.

Impact: HIGH

Impact 1: The intended 9th-treasure finder is permanently deprived of their 10 ETH reward. The on-chain contract has no mechanism to accept a new circuit or to refund an unclaimable secret — they receive nothing.

Impact 2: withdraw() at contracts/src/TreasureHunt.sol:223-232 is gated on claimsCount >= MAX_TREASURES = 10, but honest claims can reach at most 9 (one hash is unclaimable). Therefore withdraw() is permanently unreachable on the happy path. The remaining 10 ETH of contract funding becomes stuck unless the owner falls back to pause() + emergencyWithdraw() (which is documented only as an emergency escape, not a business-as-usual fund-recovery path, and forbids recipient == owner forcing a relay through a secondary address).

Combined: ~10 ETH (one reward's worth) at risk plus a broken post-hunt settlement flow + the protocol's "10 treasures / 100 ETH" user-facing promise in the README is violated.

Proof of Concept

This is a setup / circuit-data correctness finding; the evidence is in-scope static inspection + a passing existing test that silently relies on the bug.

  1. Array inspection: circuits/src/main.nr:55-66 (shown above) shows indices 8 and 9 are literally the same number.

  2. Cross-reference with Deploy: contracts/scripts/Deploy.s.sol:17-26 (the doc-comment list of intended hashes) includes -4417726114...3124552 as the 9th hash, which is absent from the circuit.

  3. Cryptographic confirmation via the contest's own test:

// circuits/src/tests.nr::test_treasure_hunt_all_treasures_success (PASSES)
#[test]
fn test_treasure_hunt_all_treasures_success() {
let treasures: [Field; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 10, 10]; //@> [1..8, 10, 10] — treasure 9 absent
for i in 0..10 {
let treasure = treasures[i];
let treasure_hash = ALLOWED_TREASURE_HASHES[i];
let recipient: Field = 2;
main(treasure, treasure_hash, recipient);
}
}

This test passes iff pedersen_hash([treasures[i]]) == ALLOWED_TREASURE_HASHES[i] for all i — and it passes with treasure 9 == 10 (the test substitutes treasure #10 for treasure #9's slot because no integer hashes to ALLOWED_TREASURE_HASHES[8] except 10).

  1. Withdraw-gating check (Solidity, in-scope):

// contracts/src/TreasureHunt.sol:223
function withdraw() external {
require(claimsCount >= MAX_TREASURES, "HUNT_NOT_OVER"); //@> gates on 10; honest claims reach at most 9
uint256 balance = address(this).balance;
require(balance > 0, "NO_FUNDS_TO_WITHDRAW");
(bool sent, ) = owner.call{value: balance}("");
require(sent, "ETH_TRANSFER_FAILED");
emit Withdrawn(balance, address(this).balance);
}

A Foundry test demonstrating the happy-path reachability failure:

function test_withdraw_bricked_on_honest_path() public {
// Simulate the ONLY-9-CLAIMS state (what honest participants can achieve).
// We use a test harness that simulates the state where 9 distinct claims
// succeeded and no further claim can succeed because treasure #9 is
// unclaimable.
for (uint256 i = 0; i < 9; i++) {
vm.prank(participant);
hunt.claim(proof, bytes32(uint256(i + 1)), payable(address(uint160(0xABCD + i))));
}
assertEq(hunt.claimsCount(), 9);
assertLt(hunt.claimsCount(), hunt.MAX_TREASURES());
// withdraw() reverts because HUNT_NOT_OVER.
vm.expectRevert(bytes("HUNT_NOT_OVER"));
hunt.withdraw();
}

Recommended Mitigation

Replace circuits/src/main.nr:64 (or the index-8 slot) with the missing 9th hash from the Deploy script's documented set, then regenerate contracts/src/Verifier.sol via circuits/scripts/build.sh:

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
- -961435057317293580094826482786572873533235701183329831124091847635547871092,
+ -4417726114039171734934559783368726413190541565291523767661452385022043124552,
-961435057317293580094826482786572873533235701183329831124091847635547871092
];

Also fix circuits/src/tests.nr::test_treasure_hunt_all_treasures_success to use treasures = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] — the test should catch the exact class of bug that's present today.

Add a uniqueness invariant test:

#[test]
fn test_allowed_hashes_are_unique() {
for i in 0..10 {
for j in (i+1)..10 {
assert(ALLOWED_TREASURE_HASHES[i] != ALLOWED_TREASURE_HASHES[j]);
}
}
}

Support

FAQs

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

Give us feedback!