AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Anyone can call MerkleAirdrop::claim() on behalf of any eligible address, enabling griefing and forced claims

Description

  • The claim() function is intended to let each eligible address collect their own 25 USDC allocation by submitting a valid merkle proof.

  • However, the function accepts an arbitrary account parameter with no verification that the caller is the account being claimed for. Because merkle proofs for all four eligible addresses are reconstructable from the public makeMerkle.js output, any third party can call claim() for every eligible address without their knowledge or consent.

function claim(address account, uint256 amount, bytes32[] calldata merkleProof)
external payable {
if (msg.value != FEE) revert MerkleAirdrop__InvalidFeeAmount();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
// @> No check that msg.sender == account
// @> No signature verification to confirm account authorized this claim
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf))
revert MerkleAirdrop__InvalidProof();
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}

Risk

Likelihood:

  • Any caller who reconstructs the four merkle proofs from the public script output triggers all four claims in a single transaction, paying only 4 × 1 Gwei in fees

  • A malicious actor front-runs a legitimate user's pending claim() transaction by submitting the same call first, causing the original transaction to revert once H-1 is fixed and s_hasClaimed is in place

Impact:

  • All four eligible addresses are force-claimed without consent — if the token has transfer restrictions, tax mechanics, or blacklist logic, receiving tokens unexpectedly may cause harm to the recipient

  • Once H-1 is mitigated with claim tracking, an attacker can permanently lock out all four legitimate claimants by front-running and force-claiming every address before the real owners act

Proof of Concept

Because account is a free parameter with no caller restriction, any address can submit a valid proof on behalf of an eligible address. The attacker only needs the four proofs — which are deterministically derivable from the public merkle root and the known eligible address list — to drain all allocations in four transactions. The scenario below illustrates how a third party who owns none of the eligible addresses can force-claim the entire airdrop:

// Conceptual attack flow:
//
// 1. Attacker is NOT one of the four eligible addresses
// but reconstructs all four proofs from makeMerkle.js output
//
// 2. Attacker force-claims all four eligible addresses:
// airdrop.claim{value: 1e9}(eligibleAddr1, 25e6, proof1);
// airdrop.claim{value: 1e9}(eligibleAddr2, 25e6, proof2);
// airdrop.claim{value: 1e9}(eligibleAddr3, 25e6, proof3);
// airdrop.claim{value: 1e9}(eligibleAddr4, 25e6, proof4);
//
// 3. Each call passes all checks:
// - msg.value == FEE → true
// - MerkleProof.verify(...) → true (valid proof for each address)
// - msg.sender == account → NOT CHECKED
//
// 4. Tokens are sent to the eligible addresses (not the attacker),
// but the attacker controls WHEN and WHETHER each person receives them.
//
// 5. Combined with H-1 fix (s_hasClaimed mapping):
// - Attacker front-runs all four legitimate claim transactions
// - s_hasClaimed[addr1..4] = true for all four addresses
// - Legitimate owners can never claim — permanently locked out
//
// 6. Total cost to attacker: 4 Gwei (~$0.00001)

Recommended Mitigation

Require that the caller is the account being claimed for, or alternatively implement EIP-712 signature verification so that the eligible address authorizes the claim off-chain and a relayer can submit it on their behalf. The simpler fix prevents any unauthorized caller from triggering a claim; the signature-based approach additionally enables gasless claims via a trusted relayer without sacrificing authorization.

function claim(address account, uint256 amount, bytes32[] calldata merkleProof)
external payable {
+ if (msg.sender != account) revert MerkleAirdrop__Unauthorized();
if (msg.value != FEE) revert MerkleAirdrop__InvalidFeeAmount();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf))
revert MerkleAirdrop__InvalidProof();
s_hasClaimed[account] = true;
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
+ // Alternative: EIP-712 signature verification
+ // Require account to sign keccak256(abi.encode(TYPEHASH, account, amount, nonce))
+ // Verify with: ECDSA.recover(digest, v, r, s) == account
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!