SNARKeling Treasure Hunt

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

Circuit omits advertised treasure 9 hash and duplicates treasure 10 hash, leaving one reward slot unclaimable

Author Revealed upon completion

Root + Impact

Description

Deploy.s.sol comments the 10 advertised treasure hashes that are meant to be the public claimable set. The Noir circuit's baked-in ALLOWED_TREASURE_HASHES array — the allow-list the ZK circuit proves against — does not match that set. The advertised ninth treasure hash is absent from the circuit, and the tenth treasure's hash appears twice. As a result, no finder of the ninth advertised treasure can produce a valid proof for it, and the hunt's normal completion path (claimsCount >= MAX_TREASURES in withdraw()) is unreachable by legitimate unique claims once the replay bug in claim() is patched.

Advertised inventory in contracts/scripts/Deploy.s.sol#L16-L26:

// Treasures' hashes (revealed to the public, used as public inputs for the proof generation):
// ...
// -4417726114039171734934559783368726413190541565291523767661452385022043124552, // @> treasure 9
// -961435057317293580094826482786572873533235701183329831124091847635547871092 // @> treasure 10

Circuit allow-list in circuits/src/main.nr#L54-L66:

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
8931814952839857299896840311953754931787080333405300398787637512717059406908,
/* entries 2..8 omitted */,
-961435057317293580094826482786572873533235701183329831124091847635547871092, // @> index 8 — should be treasure 9
-961435057317293580094826482786572873533235701183329831124091847635547871092, // @> index 9 — treasure 10 (duplicate)
];

The ninth advertised hash (-4417726114...1452385022043124552) is not present anywhere in the circuit. Treasure 10's hash appears twice, at indices 8 and 9.

Risk

Likelihood: Medium.

  • The misconfiguration is deterministic; every deployed instance of the hunt carries it.

  • It only manifests when a participant finds treasure 9 and tries to prove it. Any other discovery still produces a valid proof, so the bug is silent unless treasure 9 is found.

Impact: Medium.

  • The ninth reward slot (10 ETH) is permanently unclaimable through the legitimate flow; the circuit rejects any proof that binds that treasure hash to a recipient.

  • MAX_TREASURES cannot be reached via legitimate unique claims, which blocks withdraw()'s post-hunt exit condition and leaves residual funding dependent on the owner's emergency withdrawal path.

  • No funds are directly stolen; the failure mode is degraded availability plus forced reliance on a trusted emergency flow.

Proof of Concept

Foundry test asserts the exact mismatch between the advertised inventory and the circuit allow-list. File: contracts/test/hunter_pocs/CircuitHashSetPoC.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import {Test} from "forge-std/Test.sol";
/// @dev Pure-data assertions; no deploy needed. Encodes the advertised 10-hash inventory from
/// Deploy.s.sol comments and the circuit's baked-in ALLOWED_TREASURE_HASHES from main.nr.
contract CircuitHashSetPoC is Test {
// Field values as they appear in source; negative values are interpreted as the Field's
// additive inverse on the BN254/bn128 prime, but for this set-equality check we only need
// the bytes32/int256 representation to match between the two declarations.
int256 internal constant TREASURE_9_ADVERTISED =
int256(-4417726114039171734934559783368726413190541565291523767661452385022043124552);
int256 internal constant TREASURE_10_ADVERTISED =
int256(-961435057317293580094826482786572873533235701183329831124091847635547871092);
// Last two entries of the circuit's ALLOWED_TREASURE_HASHES array.
int256 internal constant CIRCUIT_INDEX_8 = TREASURE_10_ADVERTISED; // should equal TREASURE_9_ADVERTISED
int256 internal constant CIRCUIT_INDEX_9 = TREASURE_10_ADVERTISED;
function testAdvertisedNinthTreasureHashIsNotInCircuitAndTenthIsDuplicated() public pure {
// Advertised ninth hash is not represented in the circuit.
assertTrue(CIRCUIT_INDEX_8 != TREASURE_9_ADVERTISED, "circuit index 8 should have been treasure 9's hash");
assertTrue(CIRCUIT_INDEX_9 != TREASURE_9_ADVERTISED, "circuit index 9 should not be treasure 9's hash either");
// Tenth hash is duplicated across the last two entries.
assertEq(CIRCUIT_INDEX_8, TREASURE_10_ADVERTISED, "circuit index 8 currently equals treasure 10");
assertEq(CIRCUIT_INDEX_9, TREASURE_10_ADVERTISED, "circuit index 9 currently equals treasure 10");
}
}

Command + result:

forge test --match-test testAdvertisedNinthTreasureHashIsNotInCircuitAndTenthIsDuplicated -vvv
[PASS] testAdvertisedNinthTreasureHashIsNotInCircuitAndTenthIsDuplicated() (gas: 1783)
Suite result: ok. 1 passed; 0 failed; 0 skipped;

Recommended Mitigation

Replace ALLOWED_TREASURE_HASHES[8] in circuits/src/main.nr with the advertised ninth hash, regenerate the Noir circuit + the HonkVerifier Solidity contract + contracts/test/fixtures/proof.bin, and add a circuit test that iterates the full [1,2,3,4,5,6,7,8,9,10] treasure list instead of the current [1,2,3,4,5,6,7,8,10,10].

global ALLOWED_TREASURE_HASHES: [Field; 10] = [
/* entries 1..8 unchanged */,
- -961435057317293580094826482786572873533235701183329831124091847635547871092,
+ -4417726114039171734934559783368726413190541565291523767661452385022043124552, // treasure 9
-961435057317293580094826482786572873533235701183329831124091847635547871092, // treasure 10
];

Support

FAQs

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

Give us feedback!