SNARKeling Treasure Hunt

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

Public `recipient` field is unused in circuit constraints

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
Updates

Lead Judging Commences

s3mvl4d Lead Judge 18 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

unused "recipient" in circuit

The claim that the proof system is broken because the recipient is not explicitly constrained in the Noir circuit reflects a misunderstanding of how zero-knowledge proofs bind public inputs. Although the circuit does not impose algebraic constraints on recipient, the value is still included in the public input vector, which is cryptographically committed to during proof generation. As a result, the proof is only valid for the exact tuple of public inputs it was created with. Any attempt by an attacker to front-run and substitute a different recipient would alter this tuple, causing the verifier’s check to fail because the proof no longer matches the provided public inputs. Therefore, while unconstrained public inputs do not enforce logical relationships within the circuit, they remain inseparably bound to the proof itself, and this binding is sufficient to prevent tampering or replay with modified values. Run the unit tests 'testClaimInvalidProofFails', 'testFrontRunningClaimFails'.

Support

FAQs

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

Give us feedback!