SNARKeling Treasure Hunt

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

[H-02] Duplicate allowed hash in Noir circuit collapses two treasure slots into one claim key

Author Revealed upon completion

Root + Impact

Description

The normal behavior is to have 10 distinct secrets mapped to 10 distinct allowed Pedersen hashes.

The issue is that ALLOWED_TREASURE_HASHES contains the same value at index 8 and 9. This collapses two intended treasure slots into one cryptographic identity. Under intended one-claim-per-hash semantics, only one of those two slots can be redeemed independently.

// circuits/src/main.nr
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
// ...
-961435057317293580094826482786572873533235701183329831124091847635547871092, // @> index 8
-961435057317293580094826482786572873533235701183329831124091847635547871092 // @> index 9 (duplicate)
];

Risk

Likelihood:

  • The duplicate constant is deterministic and always active for all proofs.

  • At least one legitimate participant eventually hits the colliding slot in normal operation.

Impact:

  • One treasure reward becomes permanently uncollectable once per-hash replay protection is enforced as intended.

  • Protocol promise of 10 unique claimable treasures is violated.

Proof of Concept

Standalone reproduction in Noir:

  1. Compute pedersen_hash([9]) which equals -4417726114039171734934559783368726413190541565291523767661452385022043124552.

  2. Check membership against current ALLOWED_TREASURE_HASHES.

  3. Observe membership is false because the array duplicates hash #10 instead of including hash #9.

  4. A proof for treasure 9 cannot satisfy is_allowed(treasure_hash).

// Should fail with current constants in circuits/src/main.nr
#[test(should_fail)]
fn test_treasure_9_unclaimable_due_to_duplicate_constant() {
let treasure: Field = 9;
let treasure_9_hash: Field =
-4417726114039171734934559783368726413190541565291523767661452385022043124552;
let recipient: Field = 2;
// sanity: pedersen hash matches expected hash for 9
assert(std::hash::pedersen_hash([treasure]) == treasure_9_hash);
// fails because treasure_9_hash is missing from ALLOWED_TREASURE_HASHES
main(treasure, treasure_9_hash, recipient);
}

Written exploit flow:

  • The duplicated hash allows only one of the two intended physical treasure slots to be represented as a unique on-chain claim key.

  • Once that shared hash is claimed once (with a correct claimed[treasureHash] guard), the second participant mapped to the same hash cannot claim independently.

Recommended Mitigation

Use the global constant ALLOWED_TREASURE_HASHES in circuits/src/main.nr.

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
// ...
- -961435057317293580094826482786572873533235701183329831124091847635547871092,
+ -4417726114039171734934559783368726413190541565291523767661452385022043124552,
-961435057317293580094826482786572873533235701183329831124091847635547871092
];

After updating the circuit constant, regenerate proving artifacts and contracts/src/Verifier.sol from the new circuit.

Support

FAQs

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

Give us feedback!