AirDropper

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

MerkleAirdrop::claim() violates the Checks-Effects-Interactions pattern, enabling reentrancy for tokens with transfer hooks

Description

  • The claim() function is intended to verify eligibility and transfer the allocated token amount to the claimant exactly once per eligible address.

  • However, the function performs the external safeTransfer() call before writing any state that records the claim. This violates the Checks-Effects-Interactions (CEI) pattern: no effect (state change) is written between the checks and the external interaction. If the airdrop token is replaced with or upgraded to one that supports transfer hooks — such as ERC-777, which calls tokensReceived() on the recipient — a malicious recipient contract can re-enter claim() during the transfer, before any claimed state is recorded, and drain the contract recursively.

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))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf))
revert MerkleAirdrop__InvalidProof();
// @> No state update written here — CEI pattern violated
emit Claimed(account, amount);
// @> External call executes before any effect is recorded
// @> A token with transfer hooks can reenter claim() at this point
i_airdropToken.safeTransfer(account, amount);
}

Risk

Likelihood:

  • The current token (USDC) is a standard ERC-20 with no transfer hooks, so reentrancy is not exploitable today — however, if the contract is redeployed with a hook-enabled token, the vulnerability becomes immediately exploitable without any code change

  • A future token upgrade or a deployer error that substitutes an ERC-777-compatible token activates the reentrancy path without any warning, since the contract contains no reentrancy guard

Impact:

  • A malicious recipient contract re-enters claim() during the token transfer callback, recursively draining the entire contract balance before any claimed state is written

  • The absence of a state update before the external call also means the fix for H-1 (s_hasClaimed[account] = true) must be placed before safeTransfer() to be effective — if it is placed after, the reentrancy window remains open even with H-1 patched

Proof of Concept

The reentrancy window exists because the contract's state is identical at the start of the first call and at the start of any reentrant call — there is nothing written to storage between the merkle proof check and the token transfer. A recipient contract that implements a transfer hook can therefore call claim() again mid-transfer and pass all checks a second time, repeating recursively until the contract is drained:

// Conceptual reentrancy flow (requires hook-enabled token, e.g. ERC-777):
//
// 1. Attacker deploys a malicious contract at an eligible address
// The contract implements tokensReceived() — called on every incoming transfer
//
// 2. Attacker calls claim() for the first time:
// → fee check passes
// → merkle proof check passes
// → no state is written ← reentrancy window opens here
// → safeTransfer() is called
// → token calls tokensReceived() on attacker contract
// → attacker re-enters claim() recursively
// → fee check passes (msg.value still set)
// → merkle proof check passes (s_hasClaimed does not exist)
// → safeTransfer() called again → repeat until balance = 0
//
// 3. Final state:
// - attacker balance : entire contract token balance
// - contract balance : 0
// - s_hasClaimed : never written (no such mapping exists)

Recommended Mitigation

Move the s_hasClaimed state update (introduced as part of the H-1 fix) to before the safeTransfer() call, strictly following the Checks-Effects-Interactions order. This ensures that any reentrant call finds the account already marked as claimed and reverts immediately, closing the reentrancy window regardless of the token type used.

function claim(address account, uint256 amount, bytes32[] calldata merkleProof)
external payable {
if (s_hasClaimed[account]) revert MerkleAirdrop__AlreadyClaimed();
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();
- emit Claimed(account, amount);
- i_airdropToken.safeTransfer(account, amount);
+ // Effects before Interactions
+ s_hasClaimed[account] = true;
+ emit Claimed(account, amount);
+
+ // Interactions last
+ i_airdropToken.safeTransfer(account, amount);
}
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!