SNARKeling Treasure Hunt

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

Duplicate baked treasure hash reduces the effective treasure inventory to 9 and desynchronizes scoped artifacts

Duplicate baked treasure hash reduces the effective treasure inventory to 9 and desynchronizes scoped artifacts

Description

  • The protocol is documented and funded as a 10-treasure hunt: `README.md` states the circuit contains 10 valid treasure hashes, `Deploy.s.sol` documents 10 published hashes, and `TreasureHunt.sol` hardcodes `MAX_TREASURES = 10` with `REWARD = 10 ether`.

  • The Noir circuit does not actually contain 10 unique treasure hashes. `circuits/src/main.nr` repeats the same baked hash in the last two slots, while `Deploy.s.sol` still documents a different 9th hash (`-441772...`) that is absent from the circuit. This desynchronizes the circuit, deployment script, and operator-facing assumptions, and leaves one documented treasure outside the actual proof-valid set.

// circuits/src/main.nr
global ALLOWED_TREASURE_HASHES: [Field; 10] = [
// ...
@> -961435057317293580094826482786572873533235701183329831124091847635547871092,
@> -961435057317293580094826482786572873533235701183329831124091847635547871092,
];
// contracts/scripts/Deploy.s.sol
// @> documented hash that is missing from the circuit:
// -4417726114039171734934559783368726413190541565291523767661452385022043124552,

Risk

Likelihood: HIGH

  • The mismatch is already baked into the committed circuit source and mirrored by the scoped deployment script comments, so a deployment from this repository inherits the inconsistent treasure inventory by default.

  • Any operator or participant relying on the published hash list in `Deploy.s.sol` can be pointed to a treasure identifier that the circuit will never accept.

Impact: HIGH

  • One documented treasure becomes unclaimable through the intended ZK proof flow because its published hash is not present in the circuit's `is_allowed` set.

  • Protocol lifecycle assumptions drift out of sync: the system is funded and documented as a 10-treasure hunt even though the circuit only exposes 9 unique claimable treasure identifiers.

Proof of Concept

Add this test case to test file with any other stuff variable that depends, and run with forge test

string internal constant MISSING_DOCUMENTED_HASH = "-4417726114039171734934559783368726413190541565291523767661452385022043124552";
string internal constant DUPLICATE_CIRCUIT_HASH = "-961435057317293580094826482786572873533235701183329831124091847635547871092";
function testPoCMedium_DuplicateBakedHashMismatchesDocumentedTreasureInventory() public view {
string memory circuitSource = vm.readFile("circuits/src/main.nr");
string memory deployScript = vm.readFile("contracts/scripts/Deploy.s.sol");
string memory readme = vm.readFile("README.md");
assertEq(_countOccurrences(circuitSource, DUPLICATE_CIRCUIT_HASH), 2);
assertEq(_countOccurrences(circuitSource, MISSING_DOCUMENTED_HASH), 0);
assertEq(_countOccurrences(deployScript, MISSING_DOCUMENTED_HASH), 1);
assertGt(_countOccurrences(readme, "10 valid treasure hashes"), 0);
}
function _countOccurrences(string memory haystack, string memory needle) internal pure returns (uint256 count) {
bytes memory h = bytes(haystack);
bytes memory n = bytes(needle);
if (n.length == 0 || h.length < n.length) {
return 0;
}
for (uint256 i = 0; i <= h.length - n.length; i++) {
bool matchFound = true;
for (uint256 j = 0; j < n.length; j++) {
if (h[i + j] != n[j]) {
matchFound = false;
break;
}
}
if (matchFound) {
count++;
}
}
}

Recommended Mitigation

Update any mirrored treasure-hash documentation in `Deploy.s.sol` and related operational materials from the same source of truth used to generate the circuit.

- -961435057317293580094826482786572873533235701183329831124091847635547871092,
+ -4417726114039171734934559783368726413190541565291523767661452385022043124552,
-961435057317293580094826482786572873533235701183329831124091847635547871092,
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!