AirDropper

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

No check if already claimed in claim function


Root + Impact

Description

  • Normal behavior: claim verifies a merkle proof and transfers the specified amount of i_airdropToken to the account, charging a fixed FEE.

  • Issue: claim does not record or check whether account has already claimed, allowing the same merkle entry to be claimed repeatedly by anyone supplying the original valid proof and paying the fee each time.

// Root cause in the codebase with @> marks to highlight the relevant section
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();
}
@> emit Claimed(account, amount);
@> i_airdropToken.safeTransfer(account, amount);
}

Risk

Likelihood:

  • Valid merkle proofs are reusable for the same (account, amount) entry — anyone with the proof can repeatedly call claim and trigger a transfer each time.

  • The contract charges only a small FEE per call, so there is little economic friction to prevent repeated claims.

Impact:

  • The airdrop token supply allocated to the merkle tree can be drained beyond intended allowances by repeated claims for the same leaf.

  • Token distribution integrity is broken — recipients or attackers can obtain more tokens than allocated, causing significant financial loss and reputational damage.

Proof of Concept

// Minimal PoC: call `claim` twice for the same account/amount using the same merkle proof.
pragma solidity 0.8.24;
interface IMerkleAirdrop {
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable;
}
contract PoC {
IMerkleAirdrop public target;
constructor(IMerkleAirdrop _target) {
target = _target;
}
// Provide the correct merkleProof for (account,amount) off-chain and pass it here.
// Send at least 2 * FEE in msg.value so both calls succeed.
function exploit(address account, uint256 amount, bytes32[] calldata proof) external payable {
// First claim
target.claim{value: msg.value / 2}(account, amount, proof);
// Second claim — succeeds again because there's no claimed-state check
target.claim{value: msg.value / 2}(account, amount, proof);
}
}

Recommended Mitigation

- error MerkleAirdrop__InvalidFeeAmount();
- error MerkleAirdrop__InvalidProof();
- error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__InvalidFeeAmount();
+ error MerkleAirdrop__InvalidProof();
+ error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__AlreadyClaimed();
+ // Track which addresses / merkle leaves have already claimed
+ mapping(address => bool) private s_claimed;
@@
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();
}
- emit Claimed(account, amount);
- i_airdropToken.safeTransfer(account, amount);
+ if (s_claimed[account]) {
+ revert MerkleAirdrop__AlreadyClaimed();
+ }
+ s_claimed[account] = true;
+ i_airdropToken.safeTransfer(account, amount);
+ emit Claimed(account, amount);
}

Notes:

  • Use mapping(bytes32 => bool) s_claimedLeaf if multiple accounts share the same address but different amounts or if you prefer to key by the leaf (keccak256(abi.encode(account, amount))) to prevent same-address different-amount conflicts.

  • Mark the claimed state before transferring tokens to avoid reentrancy windows; consider following checks-effects-interactions and adding nonReentrant if there are external calls that could reenter.

Updates

Lead Judging Commences

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