SNARKeling Treasure Hunt

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

Treasure Secrets Are Trivially Brute-Forceable — All 100 ETH Drainable Without Physical Participation

Author Revealed upon completion

Treasure Secrets Are Trivially Brute-Forceable — All 100 ETH Drainable Without Physical Participation

Root Cause + Impact

The 10 treasure secrets are sequential integers (1 through 10). The entire keyspace can be brute-forced in milliseconds by computing pedersen_hash([n]) for small values of n and comparing against the public ALLOWED_TREASURE_HASHES. An attacker generates 10 valid proofs and claims all 100 ETH without ever participating in the physical treasure hunt.

Description

  • The ZK proof system is meant to guarantee that only someone who physically discovered the treasure (and therefore knows the secret) can generate a valid proof. The hashes are public; the security argument rests entirely on preimage secrecy.

  • The treasure secrets are the integers 1 through 10. Pedersen hash is preimage-resistant for cryptographically random inputs, but with a keyspace of 10 values, exhaustive search is instant. Deploy.s.sol:14-15 also leaks all secrets in plaintext, though brute-force alone is already sufficient.

// Deploy.s.sol, lines 14-15
@> // Secret Treasures for the snorkeling hunt (not revealed to the public):
@> // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

Why the ZK model collapses: the security argument requires the secret space to have high min-entropy (≥ 128 bits for cryptographic security). A keyspace of 10 values has log₂(10) ≈ 3.3 bits — less than a single decimal digit. Preimage-resistance of Pedersen hash does not help, because preimage-resistance only protects against attackers who don't know the distribution of the secret; here the distribution (small integers 1–10) is public.

Risk

Likelihood: Any observer with a laptop recovers all 10 secrets in under 1 second via brute-force against the public hashes. No physical presence, no credentials, no compromised keys required.

Impact: An attacker generates 10 valid proofs and calls claim() 10 times, draining all 100 ETH before any honest snorkeler reaches the water. The ZK privacy guarantee is completely nullified.

Proof of Concept

A remote attacker reads the public hashes from main.nr:55, brute-forces all 10 secrets against small integers (complete in <1 second), then runs nargo prove with each recovered (secret, hash, attacker_recipient) tuple. The circuit's two constraints (is_allowed(treasure_hash) + pedersen_hash([treasure]) == treasure_hash) both pass because the attacker genuinely knows the secret. 10 valid proofs are produced off-chain. The attacker then submits 10 claim() transactions — all guards in TreasureHunt.sol (balance check, recipient check, claimsCount, per-hash claimed flag, verifier.verify) pass for each distinct hash. Contract balance goes from 100 ETH to 0.

# Brute-force all 10 secrets in under 1 second
from noir_utils import pedersen_hash # pseudocode
ALLOWED = [...] # the 10 public hashes from main.nr
for n in range(1, 1000):
h = pedersen_hash([n])
if h in ALLOWED:
print(f"Secret found: treasure={n}")
# Output: all 10 secrets recovered

Recommended Mitigation

Use cryptographically strong random secrets (256-bit field elements), not sequential integers. Manage secrets via secure off-chain key management and never commit them to source code.

// Example: secrets should be random field elements like
// 0x7a3f...9c2b (256-bit random)
// NOT: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

Also remove the plaintext secret disclosure at Deploy.s.sol:14-15.

Support

FAQs

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

Give us feedback!