SNARKeling Treasure Hunt

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

Proof Theft via Frontrunning

Root + Impact

Description

In a secure ZK-claim system, the proof must be "non-malleable," meaning it should only be valid for a specific set of public inputs.

In this implementation, while the recipient is passed as a public input to the Noir circuit, it is not constrained by any logic inside the main function. In ZK terms, this is an "unconstrained" or "unused" public input. The cryptographic proof proves knowledge of the treasure secret, but it does not prove that the secret-holder intended for the funds to go to a specific recipient.

// Root cause in Noir (Circuit level)
fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
assert(is_allowed(treasure_hash));
assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
// @> The 'recipient' field is never used in a constraint.
// An attacker can change this value without breaking the proof.
}
// Root cause in Solidity (Contract level)
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
// ...
publicInputs[1] = bytes32(uint256(uint160(address(recipient))));
// @> Verifier returns 'true' even if the recipient is changed by an attacker
bool ok = verifier.verify(proof, publicInputs);
// ...
}

Risk

Likelihood: Critical

  • Automated bots can perform this attack in milliseconds.

  • No specialized cryptographic knowledge is required for the attacker; they only need to swap the recipient address in the transaction data.

Impact: High

  • Direct theft of the 10 ETH reward.

  • Legitimate hunters lose funds spent on gas and the reward itself.

Proof of Concept

Recommended Mitigation

The recipient (or msg.sender) must be cryptographically bound to the proof. This is typically done by hashing the secret and the recipient together, or by adding a constraint that ensures the proof is only valid if the recipient public input matches a value intended by the prover.

fn main(treasure: Field, treasure_hash: pub Field, recipient: pub Field) {
assert(is_allowed(treasure_hash));
- assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
+ // Constrain the recipient by including it in the hash check
+ // This forces the proof to be invalid if the recipient is changed
+ let bound_hash = std::hash::pedersen_hash([treasure, recipient]);
+ assert(bound_hash == treasure_hash);
}
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!