SNARKeling Treasure Hunt

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

Duplicate Hash in ALLOWED_TREASURE_HASHES Makes Treasure 9 Unclaimable and Permanently Locks Owner's withdraw()

Finding 2: Duplicate Hash in ALLOWED_TREASURE_HASHES Makes Treasure 9 Unclaimable and Permanently Locks Owner's withdraw()

Root + Impact

Duplicate hash in circuit array + permanent fund lock for owner

Description

  • The ALLOWED_TREASURE_HASHES array in the Noir circuit should contain 10 unique hashes, one for each of the 10 physical treasures. This allows any participant who finds a physical treasure to generate a valid ZK proof and claim their reward.

  • Indices 8 and 9 of the ALLOWED_TREASURE_HASHES array are both set to the same value -961435057317293580094826482786572873533235701183329831124091847635547871092 (the hash of treasure 10). The hash for treasure 9 (-4417726114039171734934559783368726413190541565291523767661452385022043124552, as listed in Prover.toml.example) is missing from the circuit. The is_allowed() function will therefore return false for treasure 9's hash, making it impossible to generate a valid proof for that treasure.

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
1505662313093145631275418581390771847921541863527840230091007112166041775502,
-7876059170207639417138377068663245559360606207000570753582208706879316183353,
-5602859741022561807370900516277986970516538128871954257532197637239594541050,
2256689276847399345359792277406644462014723416398290212952821205940959307205,
10311210168613568792124008431580767227982446451742366771285792060556636004770,
-5697637861416433807484703347699404695743570043365849280798663758395067508,
-2009295789879562882359281321158573810642695913475210803991480097462832104806,
8931814952839857299896840311953754931787080333405300398787637512717059406908,
-961435057317293580094826482786572873533235701183329831124091847635547871092, // @> should be hash for treasure 9
-961435057317293580094826482786572873533235701183329831124091847635547871092 // @> duplicate of above — treasure 9's hash is missing
];

The test file tests.nr also confirms awareness of this issue — treasure 9 is omitted and replaced with a second 10:

let treasures: [Field; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 10, 10]; // @> 9 is missing, 10 is duplicated

Risk

Likelihood:

  • The circuit always rejects a proof for treasure 9 since its hash is not in the allowed set — this occurs for every participant who finds treasure 9

  • claimsCount reaches at most 9 (from the remaining 9 unique valid hashes), never the required MAX_TREASURES (10), so the withdraw() condition is never satisfied

Impact:

  • The participant who physically finds treasure 9 cannot claim their 10 ETH reward — they are permanently disadvantaged despite valid real-world effort

  • The owner's withdraw() function requires claimsCount >= MAX_TREASURES (line 224), which can never be satisfied, so the owner's leftover funds are permanently locked in the contract (only recoverable via emergencyWithdraw while paused, which is a degraded fallback)

  • The duplicate hash means there are only 9 unique valid hashes, violating the stated invariant of 10 treasures

Proof of Concept

// In Noir — this will FAIL because treasure 9's hash is not in ALLOWED_TREASURE_HASHES
#[test(should_fail)]
fn test_treasure_9_is_unclaimable() {
let treasure: Field = 9;
// pedersen_hash([9]) — as listed in Prover.toml.example
let treasure_hash: Field = -4417726114039171734934559783368726413190541565291523767661452385022043124552;
let recipient: Field = 2;
main(treasure, treasure_hash, recipient); // FAILS: is_allowed() returns false
}
// After 9 legitimate claims, withdraw is permanently stuck
function testWithdrawPermanentlyLocked() public {
// Even after all claimable treasures are claimed, claimsCount == 9, not 10
// Since treasure 9's hash is missing from the circuit, claimsCount can never reach 10
vm.prank(owner);
vm.expectRevert("HUNT_NOT_OVER");
hunt.withdraw(); // Always reverts — owner funds are stuck
}

Recommended Mitigation

Replace the duplicate entry at index 8 with the correct hash for treasure 9:

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

Also fix the test to use the correct treasure:

- let treasures: [Field; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 10, 10];
+ let treasures: [Field; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
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!