AirDropper

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

Broken CEI Pattern in claim() Enables Reentrancy Drain if a Token with Transfer Hooks is Used

Broken CEI Pattern in claim() Enables Reentrancy Drain if a Token with Transfer Hooks is UsedDescription

The claim() function emits the Claimed event (Effect) before executing
the token transfer (Interaction), violating the Checks-Effects-Interactions
(CEI) pattern.
While this is not exploitable with a standard ERC20 token, if the contract
is deployed with an ERC777-compatible token or any token that triggers a
callback on transfer(), a reentrant call back into claim() is possible
before any state has been updated.
This vulnerability is directly amplified by the missing s_hasClaimed check
(C-1) — since no state is ever written before the transfer, the reentrancy
path is completely open.// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

Impact:

- If the contract is funded with an ERC777 token (or any token with transfer hooks), an attacker can re-enter `claim()` recursively.

- Combined with [C-1], this drains the full contract balance in a single transaction.

- The `Claimed` event will be emitted multiple times for the same address, corrupting off-chain indexers and analytics.

PoC


This finding is a theoretical reentrancy path contingent on token type. A standard ERC20 deployment is not exploitable. However, since the contract accepts any IERC20 and does not enforce CEI, deploying with an ERC777-compatible token exposes the protocol to full balance drain. The broken CEI order and absence of claimed status tracking are the root causes — not the token type itself.

proof of Code

// Attacker contract exploiting ERC777 tokensReceived() hook
contract ReentrancyAttacker {
MerkleAirdrop airdrop;
bytes32[] proof;
uint256 amount;
uint256 attackCount;
uint256 constant MAX_REENTRIES = 3;
uint256 constant FEE = 1e9;
constructor(address _airdrop, bytes32[] memory _proof, uint256 _amount) {
airdrop = MerkleAirdrop(_airdrop);
proof = _proof;
amount = _amount;
}
function attack() external payable {
airdrop.claim{value: FEE}(address(this), amount, proof);
}
// ERC777 callback — triggered inside safeTransfer()
// at this point: no state has been updated yet
function tokensReceived(
address, address, address, uint256,
bytes calldata, bytes calldata
) external {
if (attackCount < MAX_REENTRIES) {
attackCount++;
airdrop.claim{value: FEE}(address(this), amount, proof);
}
}
receive() external payable {}
}

Recommended Mitigation

As an adefense-in-depth measure, consider adding OpenZeppelin's ReentrancyGuard:

+ import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract MerkleAirdrop is Ownable {
+ contract MerkleAirdrop is Ownable, ReentrancyGuard {
- function claim(...) external payable {
+ function claim(...) external payable nonReentrant {
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 3 days 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!