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 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'.
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.