AirDropper

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

MerkleAirdrop::claim accepts a caller-supplied account address instead of msg.sender, allowing anyone to trigger claims for other addresses without consent

Root + Impact

Normal behavior

claim() is designed so that an eligible address calls the function to receive their USDC allocation. The intent is that the claimant initiates the transaction themselves.

Description

The issue

The function accepts an arbitrary address account parameter. The merkle leaf is constructed from this parameter rather than from msg.sender. This means any third party who holds a valid proof for a target address can call claim(victimAddress, amount, proof) and force a token transfer to victimAddress — without victimAddress ever initiating or consenting to the transaction.

// @> account is caller-supplied, not enforced as msg.sender
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
// @> leaf is built from the arbitrary account param
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
// @> tokens sent to account, not msg.sender — consent not verified
i_airdropToken.safeTransfer(account, amount);
}

Risk

Likelihood:

  • Merkle proofs are public (they are published off-chain or derivable from tree.json) — any observer can reconstruct a valid proof for any eligible address

  • Combined with the missing replay protection (Submission 1), a front-running bot watching the mempool can call claim() for all 4 eligible addresses in a single block before any of them act

Impact:

  • In jurisdictions where receiving tokens is a taxable event, forcing a token transfer to an address creates an unintended tax liability for the recipient without their consent

  • If account is a contract that cannot handle ERC-20 tokens (e.g. lacks an ERC-20 receiver), the forced transfer could lock the tokens permanently — the legitimate owner then cannot claim what is rightfully theirs

  • Interacts with replay vulnerability: without the hasClaimed fix, an attacker can use any address's proof to drain the contract to that address on their behalf

Proof of Concept

Add this test to test/MerkleAirdropTest.t.sol and run forge test --match-test test_AnyoneCanClaimForVictim -vvv:

function test_AnyoneCanClaimForVictim() public {
address attacker = makeAddr("attacker");
// attacker pays the fee on behalf of collectorOne
vm.deal(attacker, airdrop.getFee());
uint256 victimBalanceBefore = token.balanceOf(collectorOne);
vm.startPrank(attacker);
// attacker triggers claim for collectorOne without collectorOne's consent
airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
vm.stopPrank();
uint256 victimBalanceAfter = token.balanceOf(collectorOne);
// collectorOne receives tokens they never requested
assertEq(victimBalanceAfter - victimBalanceBefore, amountToCollect);
// attacker spent only 1 Gwei and paid nothing to themselves
assertEq(token.balanceOf(attacker), 0);
}

Recommended Mitigation

Replace the address account parameter with msg.sender directly.

- function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
+ function claim(uint256 amount, bytes32[] calldata merkleProof) external payable {
+ address account = msg.sender;
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);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 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!