Each of the four addresses committed in the Merkle root should be able to call claim once and receive its committed amount (README L59: "Each address will recieve 25 USDC"). The README (L65) says the contract is based on the Uniswap Merkle-Distributor, a design that marks each allocation as claimed so it can be paid only once.
claim writes no storage. The contract's only state is the FEE constant and two immutables. Because the pinned OZ 5.0.2 MerkleProof.verify is a pure function, a leaf that verifies once verifies on every later call. The same (account, amount, proof) can therefore be submitted again and again at 1e9 wei each, and every call transfers amount again until the contract's balance runs out. claim is permissionless and pays account, and every leaf and proof is public (tree.json, and the calldata of each earlier claim), so any third party can run the same loop.
Likelihood:
The replay works as soon as any eligible recipient, or anyone acting for one, sends the same claim a second time. claim is permissionless, the proofs are public, and each repeat costs only the 1e9 wei fee plus gas. The first claimer can take the whole pool before the others have claimed.
The bug fires in every configuration where claims work, including the repository's own 25e6-per-user configuration (test root 0x3b2e22da...). In the exact Deploy.s.sol configuration it is currently hidden by two separate deploy-script bugs, the wrong token address and the 25e18 leaves, which are reported separately. It becomes live as soon as either of those is fixed.
Impact:
One address receives the entire 100 USDC pool instead of its 25 USDC. In the PoC, recipient A ends with 100e6 after four identical claims, which cost 4 gwei in fees.
The other three eligible recipients lose their whole allocation. Their valid claims revert with ERC20InsufficientBalance(airdrop, 0, 25e6). Total payout is limited only by the contract's token balance: with the balance topped up to 200e6, one 25e6 leaf pays out eight times.
The PoC builds the real four-leaf tree on-chain from the four README addresses at 25e6 each, using the same sorted-pair hashing as OZ StandardMerkleTree. It asserts that the resulting root equals the repository's own test root 0x3b2e22da..., so the proofs are genuine two-element proofs. It funds the airdrop with 100e6 of the repository's test/mocks/AirdropToken.sol and shows:
a baseline in which each recipient claims once;
a self-drain by recipient A;
a drain by a non-eligible third party that routes the whole pool to A for exactly 4e9 wei;
eight payouts from a single leaf once the balance is topped up to 200e6.
Run from the repository root, with the file saved as test/PoC_AD-01.t.sol:
Result: Suite result: ok. 5 passed; 0 failed; 0 skipped.
## 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) ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.