The intended behavior of the claim() function is that only the legitimate user who generated a valid ZK proof should be able to claim the reward associated with a specific treasureHash.
However, all parameters required to execute a valid claim — including proof, treasureHash, and recipient — are submitted in plaintext and visible in the public mempool before the transaction is mined. The contract does not bind the proof to msg.sender, nor does it implement any protection such as a commit-reveal scheme or nullifier.
// Root cause in the codebase.
function claim(bytes calldata proof, bytes32 treasureHash, address payable recipient) external nonReentrant {
// Proof verification does not include msg.sender binding
bool ok = verifier.verify(proof, publicInputs);
if (!ok) revert InvalidProof();
}
Because of this, an attacker observing the mempool can copy a valid transaction submitted by a legitimate user and submit the exact same transaction with a higher gas price, causing their transaction to be mined first.
Likelihood:
Reason 1 // This occurs whenever a user submits a valid claim() transaction, as all inputs are publicly visible in the mempool before confirmation.
Reason 2 // Attackers can easily monitor the mempool and prioritize their transaction using higher gas fees
Impact:
Impact 1 // Legitimate users can have their rewards stolen before their transaction is mined.
Impact 2 //Attackers can consistently capture rewards without generating valid proofs themselves
function testFrontRunningPossible() public {
( bytes memory proof,
bytes32 treasureHash,
address payable recipient
) = _loadFixture();
address payable attackerRecipient = payable(address(0xBADBEEF));
// Attacker copies the exact transaction and front-runs
vm.prank(attacker);
hunt.claim(proof, treasureHash, recipient);
// Original user transaction would now fail or lose reward opportunity
// Show that the SAME treasureHash is reused
assertTrue(hunt.claimed(treasureHash));
// Yet it still allows another claim → proves logic bug
hunt.claim(proof, treasureHash, recipient);
// Critical proof: wrong key is being checked
assertFalse(hunt.claimed(bytes32(0))); // the actual checked key
// Option 1: Bind proof to msg.sender inside the circuit
Include msg.sender as part of publicInputs and verify it inside the ZK proof
// Option 2: Commit-reveal scheme
Users first submit a hash(commitment) of their proof
Later reveal the full proof to prevent mempool theft
// Option 3: Use a nullifier
Track used proofs or unique identifiers to prevent replay/front-running
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.