SNARKeling Treasure Hunt

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

Public `recipient` field is unused in circuit constraints

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: Documentation states the proof binds to a specific recipient EVM address; the circuit should constrain the public recipient input accordingly.

  • Problem: main declares recipient: pub Field but no assertion uses it. There is no 160-bit range check or decomposition in Noir. Whether full field public inputs versus uint160 packing on chain fully bind the payout address needs verifier-transcript analysis. Project tests already reject wrong recipient with the same proof bytes, so this may be informational.

// @> recipient is public but unused in constraints (only treasure_hash is asserted via pedersen)
fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
}

Risk

Likelihood:

  • Exploitability depends on a concrete mismatch between field semantics and on-chain uint160 packing.

  • Low without a demonstrated transcript ambiguity.

Impact:

  • Ranges from informational (Honk hashing sufficient) to medium if an encoding gap existed.

Proof of Concept

Explanation: The Noir entrypoint never constrains recipient in-circuit; binding may come entirely from the Honk verifier and public-input transcript. The repo’s own tests show claim reverts if recipient does not match the proof’s public inputs.

Supporting code — run wrong-recipient tests:

forge test --match-test testClaimWrongRecipientFails --match-test testFrontRunningClaimFails -vv

Supporting code — TreasureHunt.t.sol (wrong recipient reverts verifier; excerpt):

// contracts/test/TreasureHunt.t.sol — imports: BaseZKHonkVerifier from Verifier.sol
function testClaimWrongRecipientFails() public {
(bytes memory proof, bytes32 treasureHash, ) = _loadFixture();
address payable wrongRecipient = payable(participant);
vm.expectRevert(BaseZKHonkVerifier.SumcheckFailed.selector);
hunt.claim(proof, treasureHash, wrongRecipient);
}
function testFrontRunningClaimFails() public {
(bytes memory proof, bytes32 treasureHash) = _loadFixture();
address payable attackerRecipient = payable(address(0xBADBEEF));
vm.prank(attacker);
vm.expectRevert(abi.encodeWithSelector(BaseZKHonkVerifier.SumcheckFailed.selector));
hunt.claim(proof, treasureHash, attackerRecipient);
}

Supporting code — circuit (recipient unused in constraints):

fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
}

Conclusion for triage: Executable tests indicate recipient is enforced by the verifier, not by explicit Noir assertions; treat as informational unless a concrete encoding or transcript bug is demonstrated. Attach forge test output when submitting.

Recommended Mitigation

Explanation: If the security model requires an explicit statement “this proof pays exactly this 160-bit address,” add constraints in Noir (for example range-check recipient as an Ethereum address). If Honk public inputs and the verifier already bind recipient (as project tests suggest), document that design and ensure Solidity publicInputs encoding matches the verifier’s expected layout so reviewers do not assume recipient is unconstrained.

+// If the statement must bind to a 160-bit address: bit-decompose or range-constrain `recipient` in Noir
+// Else: document that verifier transcript alone binds recipient and align Solidity encoding

Support

FAQs

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

Give us feedback!