SNARKeling Treasure Hunt

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

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

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
];
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!