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

`MerkleAirdrop::claim` does not check whether an eligible user already claimed, allowing for repeated claims

Summary

MerkleAirdrop::claim does not track and check whether eligible users have already claimed their share of the airdrop. Consequently, eligible users can claim multiple times.

Vulnerability Details

MerkleAirdrop::claim is supposed to enable airdrop-eligible users to claim their share of the airdrop. However, the function does not track and check whether eligible users have already claimed or not. As demonstrated by the test below, this is a vulnerability that can be exploited by eligible users to claim more than their share of the airdrop via submitting multiple claim transactions:

Proof of Code
function testSameUserCanClaimMoreWithMultipleClaims() public {
uint256 noClaims = 3;
uint256 startingBalance = token.balanceOf(collectorOne);
vm.deal(collectorOne, noClaims * airdrop.getFee());
vm.startPrank(collectorOne);
for (uint256 i = 0; i < noClaims; i++) {
airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
}
vm.stopPrank();
uint256 endingBalance = token.balanceOf(collectorOne);
assertEq(endingBalance - startingBalance, amountToCollect * noClaims);
}

Impact

By repeatedly calling MerkleAirdrop::claim, airdrop-eligible users can claim more than they are eligible for. Essentially, a malicious airdrop-eligible user can

  • claim the airdrop shares intended for other eligible users who have not claimed yet,

  • drain the USDC balance of MerkleAirdrop until its balance has at least one share of airdrop amount left.

Tools Used

Manual review, Foundry.

Recommendations

Track and check which users have already claimed their share of the airdrop. Perform the following modifications in MerkleAirdrop:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
+ mapping(address => bool) public hasClaimed;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
/*//////////////////////////////////////////////////////////////
FUNCTIONS
//////////////////////////////////////////////////////////////*/
constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
}
function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
+ require(!hasClaimed[account], "You have already claimed.");
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();
}
+ hasClaimed[account] = true;
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
...
}

Note that due to another bug reported in another finding, the modified eligibility check above is this incomplete/incorrect. Taking into account both bugs, a full fix would look like as follows:

Fix for both bugs in eligibility check
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
import { MerkleProof } from "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract MerkleAirdrop is Ownable {
using SafeERC20 for IERC20;
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
+ mapping(address => bool) public hasClaimed;
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
/*//////////////////////////////////////////////////////////////
FUNCTIONS
//////////////////////////////////////////////////////////////*/
constructor(bytes32 merkleRoot, IERC20 airdropToken) Ownable(msg.sender) {
i_merkleRoot = merkleRoot;
i_airdropToken = airdropToken;
}
- function claim(address account, uint256 amount, bytes32[] calldata merkleProof) external payable {
+ function claim(uint256 amount, bytes32[] calldata merkleProof) external payable {
+ require(!hasClaimed[msg.sender], "You have already claimed.");
if (msg.value != FEE) {
revert MerkleAirdrop__InvalidFeeAmount();
}
- bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
+ bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
+ hasClaimed[msg.sender] = true;
- emit Claimed(account, amount);
- i_airdropToken.safeTransfer(account, amount);
+ emit Claimed(msg.sender, amount);
+ i_airdropToken.safeTransfer(msg.sender, amount);
}
...
}
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.