SNARKeling Treasure Hunt

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

`claim()` proofs are bound only to `(treasureHash, recipient)` — a mempool observer can copy and front-run the tx, griefing the legitimate claimant

Author Revealed upon completion

Scope: contracts/src/TreasureHunt.sol, circuits/src/main.nr

Root + Impact

Description

TreasureHunt.claim(bytes proof, bytes32 treasureHash, address payable recipient) verifies the proof against public inputs (treasureHash, recipient) only. msg.sender is NOT part of the proof's public inputs, nor is it otherwise bound into the statement the ZK circuit proves.

// circuits/src/main.nr (main)
fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
// Noir enforces constraints on the public inputs,
// so we don't need an explicit check for recipient format here.
}
// contracts/src/TreasureHunt.sol (claim)
bytes32[] memory publicInputs = new bytes32[](2);
publicInputs[0] = treasureHash;
publicInputs[1] = bytes32(uint256(uint160(address(recipient))));
bool ok = verifier.verify(proof, publicInputs);
if (!ok) revert InvalidProof();

Consequence: once Alice broadcasts a valid (proof, hash, Bob) transaction to the mempool, any third party Eve who observes it can copy the exact calldata verbatim and submit the same proof with a higher gas price from her own EOA. Eve's tx lands first; the contract:

  1. Verifies the proof successfully (public inputs unchanged).

  2. Distributes REWARD to Bob (the bound recipient — NOT to Eve).

  3. In the post-fix world (i.e., after Finding 1's double-claim bug is patched), this marks the treasure as claimed, so Alice's subsequent tx reverts with AlreadyClaimed.

Note: under the CURRENT code, Finding 1 masks this — both tx's succeed because the duplicate-claim check reads claimed[_treasureHash] (always 0). So the front-run finding becomes materially relevant after Finding 1 is fixed, which any reviewer would presumably do.

Risk

Likelihood: MEDIUM (in the post-fix world)

  • Mempool sniping is a routine pattern; it only requires a public-chain mempool observer with non-trivial gas-budget tolerance.

  • Alice has no cryptographic or economic defense on this contract — she can't choose a msg.sender-bound proof because the circuit doesn't accept one.

Impact: LOW

  • Eve cannot steal ETH: the payout still goes to Bob (the recipient bound into the proof). Bob is typically under Alice's control, so her ECONOMIC position is unharmed.

  • Eve burns gas for herself to grief Alice, consuming the claim slot. This is a denial-of-service griefing vector: Alice wasted gas on a reverting tx, and Bob still gets paid, but Alice experiences a failed UX. Cumulative griefing across 10 claims increases the attacker's gas cost but does nothing beneficial for them.

  • In pathological cases (e.g., a finder who needs to claim quickly before the contract drains due to some other issue, or before the hunt's time window expires), griefing can cause the finder to lose their chance entirely.

Proof of Concept

Assume Finding 1 has been patched (claimed[treasureHash] is now the correct key).
1. Alice finds treasure #5 and generates a valid proof
P = prove(secret=5, public=(HASH_5, Bob)).
2. Alice broadcasts: hunt.claim(P, HASH_5, Bob) with gas price 20 gwei.
3. Eve's mempool watcher sees the tx. She extracts calldata and
rebroadcasts: hunt.claim(P, HASH_5, Bob) from her own EOA with
gas price 200 gwei.
4. Eve's tx is included first. The contract:
- Verifier.verify(P, [HASH_5, Bob]) -> true (proof still valid; recipient binding unchanged)
- claimed[HASH_5] = true
- 10 ETH -> Bob
5. Alice's original tx is now included second. The contract:
- claimed[HASH_5] == true -> revert AlreadyClaimed(HASH_5)
- Alice has paid gas for nothing; Eve has paid extra gas for no gain.
6. Net: Alice griefed. Eve lost money. Bob got paid (as intended from
Alice's perspective, but via a reverted tx from her).

Recommended Mitigation

Bind msg.sender to the proof's public inputs. This requires updating both the circuit and the contract, and regenerating the verifier fixtures:

// circuits/src/main.nr
-fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
+fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field, submitter: pub Field) {
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
+ // submitter is the expected msg.sender of the on-chain claim tx;
+ // binding it here prevents mempool sniping.
}
// contracts/src/TreasureHunt.sol (claim)
-bytes32[] memory publicInputs = new bytes32[](2);
+bytes32[] memory publicInputs = new bytes32[](3);
publicInputs[0] = treasureHash;
publicInputs[1] = bytes32(uint256(uint160(address(recipient))));
+publicInputs[2] = bytes32(uint256(uint160(address(msg.sender))));

This pins the proof to a specific claim-submitting address. Mempool sniping is no longer profitable because a sniped copy would fail verification (the submitter public input doesn't match Eve's msg.sender).

Alternatively, EIP-2930 / EIP-7702 account abstraction flows could be used so claims don't hit the public mempool at all.

Disclosure

This finding was identified and written up with the assistance of an autonomous AI auditor (Anthropic Claude) — surfaced during the Claude + GPT brainstorm review of the initial 6-finding batch. The finding is filed separately from Finding 1 because Finding 1's fix is prerequisite for this one to be materially exploitable on-chain.

Support

FAQs

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

Give us feedback!