SNARKeling Treasure Hunt

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

Treasure preimages are trivially guessable integers 1..10, disclosed in scope files; the zero-knowledge secrecy property is defeated

Author Revealed upon completion

Description

The ZK protocol requires the ten treasure preimages to be known only to whoever physically finds the corresponding treasure. The in-scope repo breaks this in two independent ways.

  1. contracts/scripts/Deploy.s.sol:14-15 literally discloses them:

// Secret Treasures for the snorkeling hunt (not revealed to the public):
// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
  1. circuits/src/tests.nr:15, 30 hardcode the same integers as test preimages, confirming the hashes baked into circuits/src/main.nr:55-66 correspond to pedersen_hash([k]) for k in 1..10.

Even if both files were stripped, the preimages remain trivially brute-forceable: ALLOWED_TREASURE_HASHES is public in the generated verification key, and testing pedersen_hash([k]) for k = 0, 1, 2, ... recovers every secret within the first handful of iterations.

Risk

Likelihood: certain. Anyone who reads the public contest repo already knows every secret; anyone with only the public verification key recovers them in seconds. Impact: up to 100 ETH drained (90 ETH if only the dedup bug is also fixed) without any physical treasure being found. The ZK primitive adds no security when the secret space is effectively ten small integers.

Proof of Concept

$ sed -n '14,15p' contracts/scripts/Deploy.s.sol
// Secret Treasures for the snorkeling hunt (not revealed to the public):
// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
$ sed -n '30p' circuits/src/tests.nr
let treasures: [Field; 10] = [1, 2, 3, 4, 5, 6, 7, 8, 10, 10];

Noir test that confirms every reachable small-integer preimage is accepted (add to circuits/src/tests.nr):

#[test]
fn test_small_integers_are_accepted_preimages() {
let secrets: [Field; 9] = [1, 2, 3, 4, 5, 6, 7, 8, 10];
for i in 0..9 {
let s = secrets[i];
let h = std::hash::pedersen_hash([s]);
assert(is_allowed(h));
}
}

Brute-force pseudocode given only the public ALLOWED_TREASURE_HASHES:

for k in range(0, 100):
h = pedersen_hash([k])
if h in ALLOWED:
print(f"secret {k} -> {h}") # prints the nine reachable preimages

Recommended Mitigation

Before any mainnet deployment the organiser must:

  1. Generate ten cryptographically random Fr-element preimages off-chain (secure CSPRNG, reduced into BN254 Fr).

  2. Physically attach each secret to its corresponding treasure; keep no other copies.

  3. Recompute ALLOWED_TREASURE_HASHES and update circuits/src/main.nr. Regenerate Verifier.sol via circuits/scripts/build.sh.

  4. Strip the disclosure at contracts/scripts/Deploy.s.sol:14-15. Replace the hardcoded preimages in circuits/src/tests.nr:15, 30 with dummy values outside the allow-list.

Defence in depth — refuse low-entropy preimages at the circuit level:

fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
+ assert(treasure as u128 > 2**120); // refuse small-integer secrets
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
}

Support

FAQs

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

Give us feedback!