AirDropper

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

MerkleAirdrop::claim does not verify that msg.sender is the entitled account, allowing any address to claim on behalf of others

Root + Impact

Description

  • MerkleAirdrop::claim is intended to allow eligible addresses to collect their own airdrop allocation by providing a valid Merkle proof.

  • The function accepts account as a caller-supplied parameter but never verifies that msg.sender == account. Any third party who has observed a valid (account, amount, proof) tuple — from a prior on-chain transaction, a public Merkle tree, or off-chain distribution list — can submit that proof themselves. Combined with the missing double-claim guard (Finding 1), a single observed proof is sufficient for any address to drain the entire contract.

function claim(
address account, // @> caller-supplied, never compared to msg.sender
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);
// @> tokens go to account, but msg.sender is never validated
i_airdropToken.safeTransfer(account, amount);
}

Risk

Likelihood:

  • Merkle proofs are deterministic and public — any proof submitted in a prior transaction is permanently visible on-chain

No special access, private key, or privileged position is required — any EOA or contract can call claim() with another address's proof

  • The cost to do so is exactly 1e9 wei per call (~$0.000003)

Impact:

  • Any address can trigger claims for any eligible account without their consent

Combined with the missing claimed-check, a single observer can replay one proof repeatedly and drain the full contract balance before legitimate claimants act

  • Legitimate users can be griefed: their allocation is claimed and sent to their address, but the contract is drained before they attempt their own transaction

Proof of Concept

The first test shows a stranger — an address with no eligibility and no relationship to collectorOne — successfully submitting collectorOne's proof. The second test shows the full consequence: the same stranger replays that proof four times and empties the contract entirely, costing under $0.00002 in total fees.

function testPoc_StrangerClaimsForCollector() public {
uint256 fee = airdrop.getFee();
vm.deal(stranger, fee);
// stranger submits collectorOne's proof — msg.sender != account
vm.prank(stranger);
airdrop.claim{ value: fee }(collectorOne, amountToCollect, proof);
// tokens reach collectorOne, but stranger initiated without authorization
assertEq(token.balanceOf(collectorOne), amountToCollect);
assertEq(token.balanceOf(stranger), 0);
}
function testPoc_StrangerDrainsViaPublicProof() public {
uint256 fee = airdrop.getFee();
vm.deal(stranger, fee * 4);
// stranger drains full contract using only collectorOne's public proof
vm.startPrank(stranger);
airdrop.claim{ value: fee }(collectorOne, amountToCollect, proof);
airdrop.claim{ value: fee }(collectorOne, amountToCollect, proof);
airdrop.claim{ value: fee }(collectorOne, amountToCollect, proof);
airdrop.claim{ value: fee }(collectorOne, amountToCollect, proof);
vm.stopPrank();
// entire 100 USDC contract balance is gone
assertEq(token.balanceOf(address(airdrop)), 0);
}

Recommended Mitigation

Require the claimant to prove ownership of account by signing a message with its private key. Verify the signature on-chain using ECDSA before proceeding. This ensures only the legitimate key holder can initiate a claim, regardless of whether their proof is public.

+ import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
+ import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
+ error MerkleAirdrop__InvalidSignature();
function claim(
address account,
uint256 amount,
bytes32[] calldata merkleProof,
+ uint8 v, bytes32 r, bytes32 s
) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
+ bytes32 digest = _hashTypedDataV4(
+ keccak256(abi.encode(MESSAGE_TYPEHASH, account, amount))
+ );
+ address signer = ECDSA.recover(digest, v, r, s);
+ if (signer != account) {
+ revert MerkleAirdrop__InvalidSignature();
+ }
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 4 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!