AirDropper

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

Issue #2: No authorization check on claim() — anyone can claim on behalf of any account

uint256 claimantBalanceBefore = token.balanceOf(claimant);
// attacker triggers the claim, not `claimant` themselves
vm.prank(attacker);
airdrop.claim{value: fee}(claimant, claimAmount, proof);
uint256 claimantBalanceAfter = token.balanceOf(claimant);
// claimant received tokens without ever calling claim() themselves
assertEq(claimantBalanceAfter - claimantBalanceBefore, claimAmount);

function test_thirdPartyCanForceClaimForAnotherAccount() public {
uint256 fee = airdrop.getFee();
vm.deal(attacker, 1


Description

  • Describe the normal behavior in one or more sentencesypically an airdrop claim should be initiated by (or explicitly authorized by) the recipient account itself.

    The issue: claim() takes account as an arbitrary parameter with no signature or msg.sender == account check. Anyone can call claim() and pass in any eligible address as account, forcing a claim on their behalf. Combined with the fee requirement, this means a third party could pay the fee and force-claim tokens for someone else without their consent — timing they didn't choose, potentially before they wanted their allocation revealed or moved.


function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
@> // @> no check that msg.sender == account, nor any signature from account authorizing this claim
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:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 1:Any Merkle leaf (account + amount) becomes public once revealed in the airdrop's proof-generation data or after any related on-chain activity, so a third party has everything needed to trigger a claim for someone else.

  • Reason 2: No signature, msg.sender check, or opt-in mechanism gates who can initiate a claim.


Impact:

  • Impact 1:Tokens can be sent to an account without that account's consent or chosen timing, which may be undesirable for accounts wanting privacy, tax-timing control, or simply choosing not to claim yet.

  • Impact 2: While tokens still land in the correct account (not stolen), it removes recipient control over the claim action — a low/medium severity design flaw rather than a fund-loss bug. Proof of Concept

function test_thirdPartyCanForceClaimForAnotherAccount() public {
// aliceAccount never calls claim themselves
vm.deal(bob, 10 ether);
vm.prank(bob);
airdrop.claim{value: FEE}(aliceAccount, aliceAmount, aliceProof);
// aliceAccount now holds tokens despite never interacting with the contract
assertEq(token.balanceOf(aliceAccount), aliceAmount);
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { Test } from "forge-std/Test.sol";
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MerkleAirdropRepeatClaimTest is Test {
MerkleAirdrop airdrop;
IERC20 token;
address attacker = makeAddr("attacker");
address claimant; // account tied to the leaf below
uint256 claimAmount; // amount tied to the leaf below
bytes32[] proof; // valid merkle proof for (claimant, claimAmount)
function setUp() public {
// TODO: deploy token + airdrop exactly as done in the project's existing setUp(),
// and populate `claimant`, `claimAmount`, and `proof` with a real leaf from the
// project's merkle tree generation script (usually under script/ or test/).
}
function test_claimCanBeRepeatedIndefinitely() public {
uint256 fee = airdrop.getFee();
vm.deal(attacker, 1 ether);
uint256 poolBefore = token.balanceOf(address(airdrop));
vm.startPrank(attacker);
// First claim - intended, legitimate use
airdrop.claim{value: fee}(claimant, claimAmount, proof);
// Replay the exact same call - should revert, but doesn't
airdrop.claim{value: fee}(claimant, claimAmount, proof);
airdrop.claim{value: fee}(claimant, claimAmount, proof);
vm.stopPrank();
uint256 poolAfter = token.balanceOf(address(airdrop));
// Pool drained by 3x the intended single-claim amount
assertEq(poolBefore - poolAfter, claimAmount * 3);
}
}
function test_thirdPartyCanForceClaimForAnotherAccount() public {
uint256 fee = airdrop.getFee();
vm.deal(attacker, 1 ether);
uint256 claimantBalanceBefore = token.balanceOf(claimant);
// attacker triggers the claim, not `claimant` themselves
vm.prank(attacker);
airdrop.claim{value: fee}(claimant, claimAmount, proof);
uint256 claimantBalanceAfter = token.balanceOf(claimant);
// claimant received tokens without ever calling claim() themselves
assertEq(claimantBalanceAfter - claimantBalanceBefore, claimAmount);
}

Recommended Mitigation

function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
+ if (msg.sender != account) {
+ revert MerkleAirdrop__NotAccountOwner();
+ }
...
Updates

Lead Judging Commences

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