SNARKeling Treasure Hunt

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

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

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);
}
Updates

Lead Judging Commences

s3mvl4d Lead Judge 18 days ago
Submission Judgement Published
Validated
Assigned finding tags:

secrets stored in plain text

In `Deploy.s.sol`, the comments explicitly list the “Secret Treasures for the snorkeling hunt” as 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, and `circuits/Prover.toml.example` likewise stores the full treasure array in plaintext alongside the corresponding `treasure_hash` values. Since the Noir circuit proves knowledge of one of these treasure secrets by checking that `pedersen_hash([treasure]) == treasure_hash`, publishing the raw treasure inputs defeats the intended secrecy assumption behind the treasure-hunt design: anyone with repository access can recover valid witnesses and generate proofs without actually discovering the treasure in the real world.

secrets can be brute-forced

The protocol’s “secret” is not drawn from a high-entropy space; instead, the treasure values are just small scalar integers, and the circuit proves validity by checking whether the public `treasure_hash` equals `pedersen_hash([treasure])`. An attacker can offline-enumerate all plausible treasure values, compute their Pedersen hashes, and recover the matching secret with negligible effort.

Support

FAQs

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

Give us feedback!