SNARKeling Treasure Hunt

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

Treasure witness domain is limited to a tiny enumerable set

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The private witness should have large entropy so offline guessing cannot recover valid proofs faster than legitimate discovery.

  • Problem: Fixtures and deploy commentary bind treasure to the strings 1 through 10. The private input space is tiny, so an adversary can enumerate candidates offline against the fixed allowlist and produce valid proofs without physical participation.

// Prover.toml.example — treasure column aligned with contest fixtures
treasure = ["1", "2", ... "10"]
// @> circuit only checks H(treasure) == treasure_hash for one Field witness
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);

Risk

Likelihood:

  • Ten candidates fit in a short offline loop once public treasure_hash values are known.

  • The contest README and example inputs document the same small domain.

Impact:

  • The real-world hunt stops being a strong differentiator versus pure off-chain grinding.

  • First mover with automation can claim before honest participants who rely on physical discovery.

Proof of Concept

Explanation: The shipped example binds each row’s private treasure to a single-digit string ("1""10"), so the search space is 10 candidates per treasure, not a large random secret. After build.sh materializes a witness from that row, the same valid proof bytes work with TreasureHunt.claim as in snarkeling-1 (no physical discovery required).

Supporting code — build fixture and run on-chain PoC (from contest repo root):

cd circuits
TREASURE_INDEX=0 ./scripts/build.sh
cd ..
forge test --match-test testPoC_RepeatedClaimDrainsAllRewards -vv

Supporting code — the Foundry test proves one fixture proof pays out repeatedly (same root issue as snarkeling-1; here it demonstrates the small-witness fixture is sufficient to drain the pool):

// contracts/test/TreasureHuntPoC.t.sol
function testPoC_RepeatedClaimDrainsAllRewards() public {
(bytes memory proof, bytes32 treasureHash, address payable recipient) = _fixture();
uint256 recipientBefore = recipient.balance;
vm.startPrank(PARTICIPANT);
for (uint256 i = 0; i < 10; i++) {
hunt.claim(proof, treasureHash, recipient);
}
vm.stopPrank();
assertEq(hunt.claimsCount(), 10);
assertEq(address(hunt).balance, 0);
assertEq(recipient.balance, recipientBefore + (10 * hunt.REWARD()));
}

Offline enumeration (conceptual): For a target treasure_hash, at most 10 treasure strings from Prover.toml.example need to be tried in nargo until pedersen_hash([treasure]) == treasure_hash and is_allowed succeed; then bb prove yields a valid proof.bin for claim.

Recommended Mitigation

Explanation: Demo fixtures that use ten tiny string witnesses make offline brute force trivial. For production, draw high-entropy secrets per treasure, optionally commit to them (hash or Merkle root) before the hunt opens, and keep witnesses out of public repos. Align Prover.toml examples with production entropy policy or label them clearly as non-production.

-// Use single-digit string treasures in fixtures for demos only
+// Production: 256-bit+ random secrets per treasure; publish commitments before the hunt

Support

FAQs

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

Give us feedback!