SNARKeling Treasure Hunt

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

Duplicate hash in ALLOWED_TREASURE_HASHES - treasure #9 is undiscoverable, 10 ETH permanently stranded, withdraw() bricked

Author Revealed upon completion

Root + Impact

Description

`circuits/src/main.nr` lines 55-66 defines the set of 10 valid treasure hashes, but indices 8 and 9 contain the same value:

```noir
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
// ... 0..7 unique ...
8931814952839857299896840311953754931787080333405300398787637512717059406908,
-961435057317293580094826482786572873533235701183329831124091847635547871092, // index 8 duplicate
-961435057317293580094826482786572873533235701183329831124091847635547871092 // index 9 duplicate
];
```

Both `Prover.toml.example` and `Deploy.s.sol` (lines 25-26) show that index 8 was supposed to be `-4417726114039171734934559783368726413190541565291523767661452385022043124552` (pedersen hash of secret `"9"`). A copy-paste error left both circuit slots holding the hash of secret `"10"`, so the hash for secret `"9"` never enters the `is_allowed` set. The bug is further visible in `circuits/src/tests.nr` line 30, where the test fixture `[1,2,3,4,5,6,7,8,10,10]` was adjusted to pass the broken circuit instead of fixing it.

Risk

Likelihood: High - the defect triggers deterministically for every participant who finds physical treasure #9; it is baked into the circuit's proving key.

Impact

  • The hunt has 10 physical treasures but only 9 are redeemable on-chain; treasure #9 is permanently undiscoverable.

  • `claimsCount` can only ever reach `9`, so `withdraw()` (line 223, `require(claimsCount >= MAX_TREASURES)`) permanently reverts.

  • 10 ETH is permanently stranded (1 unclaimed treasure * `REWARD`), recoverable only through `pause() + emergencyWithdraw()`.

  • The "10 distinct winners" invariant is silently violated - a finder of treasure #9 completes the physical hunt but proof generation fails.

Proof of Concept

Exploit steps:

  1. Contract deployed with `MAX_TREASURES = 10`, funded with 100 ETH.

  2. Participants find treasures #1-#8 and #10; each generates a valid proof (their hashes exist in `ALLOWED_TREASURE_HASHES`). Each calls `claim()` successfully; `claimsCount` reaches 9.

  3. Last participant finds treasure #9. Off-chain proof-generation computes `pedersen_hash("9") = -4417...`, but circuit's `assert(is_allowed(treasure_hash))` on line 31 fails because `-4417...` is not in the baked-in set.

  4. The participant has no valid proof; `claim()` cannot be submitted. `claimsCount` stays at 9 forever.

  5. `withdraw()` reverts for everyone because `claimsCount < MAX_TREASURES (10)`. 10 ETH permanently stranded through the intended path.

Three-file consistency check confirms the defect:

```text
main.nr [8] = -961435... <-- duplicate of [9]
Prover.toml [8] = -4417... <-- intended value
Deploy.s.sol line 25 = -4417... <-- intended value
tests.nr treasures[] = [1,..,8,10,10] <-- test was adjusted, not circuit
```

Recommended Mitigation

Replace the duplicated hash on line 65 of `circuits/src/main.nr`:

```diff

  • -961435057317293580094826482786572873533235701183329831124091847635547871092,

  • -4417726114039171734934559783368726413190541565291523767661452385022043124552,
    -961435057317293580094826482786572873533235701183329831124091847635547871092
    ```

Then rerun `circuits/scripts/build.sh` to regenerate the compiled circuit, `Verifier.sol`, and Foundry fixtures. After the fix, treasure #9 produces a valid proof, `claimsCount` reaches 10, and `withdraw()` unlocks normally. Also fix `tests.nr` line 30 to `[1,2,3,4,5,6,7,8,9,10]` so the test actually exercises the full hunt.

For defense-in-depth, add a Noir test asserting `ALLOWED_TREASURE_HASHES` contains 10 distinct elements (pairwise non-equal) so this copy-paste class of bug cannot reappear. Mirror the list in a Solidity constant compared against circuit output at deploy time.

Support

FAQs

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

Give us feedback!