AirDropper

AI First Flight #5
Beginner FriendlyDeFiFoundry
EXP
View results
Submission Details
Severity: high
Valid

[H-2] `MerkleAirdrop::claim` never records who has claimed, letting a single eligible address claim repeatedly and drain the entire airdrop

Description

  • Each of the four eligible addresses should be able to claim its allocation exactly once, and the total amount distributed should never exceed the 100 USDC funded into the contract.

  • claim verifies the merkle proof and transfers the tokens, but it never records that account has already claimed. The merkle proof for a leaf is public on-chain data and can be reused, so the same (account, amount, proof) tuple can be submitted any number of times. An eligible address can call claim repeatedly and receive its allocation on every call, draining the pool before the other recipients ever claim.

// src/MerkleAirdrop.sol — claim()
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 records that `account` has already claimed -> the proof stays valid forever
emit Claimed(account, amount);
@> i_airdropToken.safeTransfer(account, amount); // executes again on every repeated call
}

Risk

Likelihood: HIGH

  • The proof required by claim is public data and nothing in the function prevents the same proof from being replayed; a single eligible address can loop claim in one transaction sequence.

  • The only cost per extra claim is the 1e9 wei fee (1 gwei), which is negligible.

Impact: HIGH

  • The first eligible address to act takes the entire 100 USDC (4× its 25 USDC entitlement); the remaining three recipients receive nothing — their funds are stolen.

  • The distribution invariant (each address gets exactly its allocation, total distributed ≤ funded amount) is fully broken.

Severity: HIGH (High likelihood × High impact)

Proof of Concept

The test funds the contract with the intended 4 × 25 USDC, then has one eligible address (collectorOne) call claim four times with the same proof. It ends up with all 100 USDC and the contract is emptied. (A self-consistent 6-decimal mock tree is used so the replay is demonstrated independently of H-1.) See test/PoC_H2_Replay.t.sol:

function test_H2_ReplayDrainsAirdrop() public {
AirdropToken token = new AirdropToken();
MerkleAirdrop drop = new MerkleAirdrop(merkleRoot, token);
token.mint(address(this), amountToCollect * 4); // fund 100 USDC
token.transfer(address(drop), amountToCollect * 4);
​
uint256 fee = drop.getFee();
vm.deal(collectorOne, fee * 4);
​
vm.startPrank(collectorOne);
for (uint256 i; i < 4; ++i) {
drop.claim{ value: fee }(collectorOne, amountToCollect, proof); // SAME proof reused
}
vm.stopPrank();
​
assertEq(token.balanceOf(collectorOne), amountToCollect * 4); // took all 100 USDC
assertEq(token.balanceOf(address(drop)), 0); // pool drained
}
$ forge test --match-path test/PoC_H2_Replay.t.sol -vvvv
[PASS] test_H2_ReplayDrainsAirdrop() (gas: 1925414)
# trace shows 4x claim{value:1e9}(collectorOne, 25e6, proof) each transferring 25e6,
# final balanceOf(collectorOne) = 100000000, balanceOf(airdrop) = 0

Recommended Mitigation

Record each claim and reject repeats, updating state before the transfer (Checks-Effects-Interactions):

+ mapping(address => bool) private s_hasClaimed;
+ error MerkleAirdrop__AlreadyClaimed();
​
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) revert MerkleAirdrop__InvalidFeeAmount();
+ if (s_hasClaimed[account]) revert MerkleAirdrop__AlreadyClaimed();
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) revert MerkleAirdrop__InvalidProof();
+ s_hasClaimed[account] = true; // effect before interaction
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] Eligible users can claim their airdrop amounts over and over again, draining the contract

## Description A user eligible for the airdrop can verify themselves as being part of the merkle tree and claim their airdrop amount. However, there is no mechanism enabled to track the users who have already claimed their airdrop, and the merkle tree is still composed of the same user. This allows users to drain the `MerkleAirdrop` contract by calling the `MerkleAirdrop::claim()` function over and over again. ## Impact **Severity: High**<br/>**Likelihood: High** A malicious user can call the `MerkleAirdrop::claim()` function over and over again until the contract is drained of all its funds. This also means that other users won't be able to claim their airdrop amounts. ## Proof of Code Add the following test to `./test/MerkleAirdrop.t.sol`, ```javascript function testClaimAirdropOverAndOverAgain() public { vm.deal(collectorOne, airdrop.getFee() * 4); for (uint8 i = 0; i < 4; i++) { vm.prank(collectorOne); airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof); } assertEq(token.balanceOf(collectorOne), 100e6); } ``` The test passes, and the malicious user has drained the contract of all its funds. ## Recommended Mitigation Use a mapping to store the addresses that have claimed their airdrop amounts. Check and update this mapping each time a user tries to claim their airdrop amount. ```diff contract MerkleAirdrop is Ownable { using SafeERC20 for IERC20; error MerkleAirdrop__InvalidFeeAmount(); error MerkleAirdrop__InvalidProof(); error MerkleAirdrop__TransferFailed(); + error MerkleAirdrop__AlreadyClaimed(); uint256 private constant FEE = 1e9; IERC20 private immutable i_airdropToken; bytes32 private immutable i_merkleRoot; + mapping(address user => bool claimed) private s_hasClaimed; ... 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(); } + s_hasClaimed[account] = true; emit Claimed(account, amount); i_airdropToken.safeTransfer(account, amount); } ``` Now, let's unit test the changes, ```javascript function testCannotClaimAirdropMoreThanOnceAnymore() public { vm.deal(collectorOne, airdrop.getFee() * 2); vm.prank(collectorOne); airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof); vm.prank(collectorOne); airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof); } ``` The test correctly fails, with the following logs, ```shell Failing tests: Encountered 1 failing test in test/MerkleAirdropTest.t.sol:MerkleAirdropTest [FAIL. Reason: MerkleAirdrop__AlreadyClaimed()] testCannotClaimAirdropMoreThanOnceAnymore() (gas: 96751) ```

Support

FAQs

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

Give us feedback!