SNARKeling Treasure Hunt

First Flight #59
Beginner FriendlyGameFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Noir circuit ALLOWED_TREASURE_HASHES has a duplicate at index 9 and omits treasure #9's hash — making treasure #9 permanently unclaimable and locking 10 ETH

Root + Impact

Description

Normal behaviour: the Noir circuit must enforce that a submitted treasure_hash is one of the 10 pre-baked treasure commitments, so participants can only produce valid proofs for the 10 legitimate treasures hidden in the real-world hunt. The Solidity TreasureHunt.sol contract exposes a matching MAX_TREASURES = 10 and is funded with 10 × 10 ETH = 100 ETH so each legitimate finder gets a 10 ETH reward.

Specific issue: ALLOWED_TREASURE_HASHES in circuits/src/main.nr lists only 9 unique hashes — the last two entries (index 8 and index 9) are byte-for-byte identical. Cross-referencing with the canonical list in contracts/scripts/Deploy.s.sol (lines 17-26) confirms that the correct index-8 hash — -4417726114039171734934559783368726413190541565291523767661452385022043124552 (the hash of treasure secret #9) — is missing from the circuit and was replaced by a second copy of treasure #10's hash -961435057317293580094826482786572873533235701183329831124091847635547871092.

// circuits/src/main.nr
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
// @> index 8: should be -4417726114039171734934559783368726413190541565291523767661452385022043124552
// @> (the hash of treasure #9, per Deploy.s.sol line 25)
-961435057317293580094826482786572873533235701183329831124091847635547871092,
// @> index 9: same value duplicated instead of being a distinct entry
-961435057317293580094826482786572873533235701183329831124091847635547871092
];

Because is_allowed(hash) iterates over the 10 slots and only returns true if hash equals one of the stored values, the hash of treasure #9 (pedersen_hash of secret 9) can never satisfy the circuit's membership check. The Noir main circuit therefore cannot produce a valid proof for treasure #9, and the Solidity claim() function can never accept one.

Risk

Likelihood:

  • Certain — the duplicate is compiled into the circuit at deployment time; any participant who physically finds treasure #9 and tries to claim their reward fails proof generation immediately (is_allowed returns false).

  • The bug is deterministic: no adversary, no timing, no edge case needed. It will manifest the first time a real participant locates treasure #9.

Impact:

  • 10 ETH effectively locked in the contract: 10 rewards were funded but only 9 can ever be claimed. The 10th reward sits idle.

  • claimsCount can never reach MAX_TREASURES = 10, so the post-hunt withdraw() path (which gates on claimsCount >= MAX_TREASURES) is unreachable. The owner is forced to rely on the emergency pathway (pause() + emergencyWithdraw) to recover the stranded 10 ETH, which is not the intended operational flow.

  • Reputational / UX damage: the participant who locates treasure #9 in the real world believes they have found a valid treasure, invests the effort to generate a proof, and is inexplicably denied — with no way of knowing that the underlying circuit is to blame.

Proof of Concept

  1. Inspect circuits/src/main.nr lines 55-66 and compare with contracts/scripts/Deploy.s.sol lines 17-26. The canonical list in Deploy.s.sol has 10 distinct hashes; the circuit array has ALLOWED_TREASURE_HASHES[8] == ALLOWED_TREASURE_HASHES[9].

  2. Resulting membership check in the circuit:

fn is_allowed(hash: Field) -> bool {
let mut ok = false;
for i in 0..10 {
if hash == ALLOWED_TREASURE_HASHES[i] {
ok = true;
}
}
ok
}

Let H9 = pedersen_hash(9) be the canonical hash of treasure secret #9. Because H9 == -4417726...552 and -4417726...552 is absent from the circuit array, iterating over the 10 slots never finds a match. is_allowed(H9) returns false and the circuit's assertion assert(is_allowed(treasure_hash)) fails. No valid proof can be generated for treasure #9, so TreasureHunt.claim(proof, H9, recipient) can never succeed.

  1. Observable end-state:

  • claimsCount caps at 9 (the other 9 distinct secrets still work).

  • address(hunt).balance permanently holds ≥ 10 ETH after all possible claims.

  • withdraw() reverts with HUNT_NOT_OVER forever.

Recommended Mitigation

Replace index 8 with the correct hash of treasure #9 and keep index 9 as the hash of treasure #10:

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

After the fix, the circuit accepts proofs for all 10 distinct treasures, the full 100 ETH treasury can be distributed, and claimsCount reaches MAX_TREASURES so the withdraw() post-hunt flow becomes reachable.

Additional hardening (defence in depth): add a compile-time check / unit test in the Noir project that asserts ALLOWED_TREASURE_HASHES contains 10 distinct entries (the Noir test file tests.nr already exists in the repo and is the natural place for this invariant). A similar off-chain parity test should confirm the circuit's array matches the deployment script's list element-for-element, so any future edit to one without the other is caught immediately.

Updates

Lead Judging Commences

s3mvl4d Lead Judge 18 days ago
Submission Judgement Published
Validated
Assigned finding tags:

unclaimable treasure / bricked withdraw path

The issue stems from a mismatch between the circuit and the contract’s economic assumptions: the Solidity contract is configured for `MAX_TREASURES = 10` and only allows the owner to call `withdraw()` once `claimsCount >= MAX_TREASURES`, while the Noir circuit’s baked-in `ALLOWED_TREASURE_HASHES` array does not actually contain ten distinct treasures because one hash is duplicated and another expected hash is missing. As a result, under the intended one-claim-per-treasure design described in the README, there are only nine uniquely claimable treasures even though the system is funded and accounted as if ten rewards can be legitimately redeemed. That creates two linked consequences from the same root cause: first, one treasure is effectively unclaimable because no valid proof can ever be generated for the missing allowed hash, and second, the normal “hunt over” withdrawal path becomes bricked because honest participants can never reach ten legitimate unique claims, leaving the post-hunt fund recovery logic via `withdraw` function permanently unreachable. The owner can still intervene through the emergency path.

Support

FAQs

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

Give us feedback!