Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Balance-Based Claim Manipulation

Balance-Based Claim Manipulation

Summary

The airdrop claim amount is determined by the user's current Snow token balance rather than a fixed amount encoded in the Merkle tree. This design enables sophisticated flash loan attacks to claim disproportionate NFT amounts.

Vulnerability Details

// src/SnowmanAirdrop.sol:75-88
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) {
// ...
uint256 amount = i_snow.balanceOf(receiver); // <- Here
// Merkle proof validation uses this dynamic amount
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
// NFT minting based on manipulated balance
i_snowman.mintSnowman(receiver, amount);
}

Impact

Attackers can claim NFTs worth far more than their actual investment.

Proof of Concept

contract FlashLoanExploit {
function executeAttack() external {
// 1. Flash loan 10,000 Snow tokens
uint256 loanAmount = 10_000e18;
flashLoanProvider.flashLoan(loanAmount);
}
function onFlashLoan(uint256 amount) external {
// 2. Now have 10,000 Snow tokens temporarily
// 3. Generate Merkle proof for current balance
// 4. Sign EIP-712 message with inflated balance
// 5. Call claimSnowman() → receive 10,000 NFTs
airdrop.claimSnowman(address(this), proof, v, r, s);
// 6. Repay flash loan
// 7. Keep 10,000 NFTs acquired with 0 permanent investment
}
}

Recommendations

Option 1: Fixed Amount in Merkle Tree

// Encode fixed amounts in Merkle leaves
struct ClaimData {
address receiver;
uint256 fixedAmount; // Pre-determined amount
}
// Verify exact match between proof and claim
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, claimAmount))));
require(MerkleProof.verify(merkleProof, i_merkleRoot, leaf), "Invalid proof");
// Mint the pre-approved amount
i_snowman.mintSnowman(receiver, claimAmount);

Option 2: Minimum Holding Period

mapping(address => uint256) public firstSnowAcquisition;
modifier requiresHoldingPeriod(address user) {
require(
block.timestamp >= firstSnowAcquisition[user] + MINIMUM_HOLDING_PERIOD,
"Insufficient holding period"
);
_;
}

Option 3: Snapshot-Based Claims

// Take balance snapshot at specific block
mapping(address => uint256) public snapshotBalances;
uint256 public snapshotBlock;
function claimSnowman(address receiver, uint256 snapshotAmount, bytes32[] calldata merkleProof) external {
require(snapshotBalances[receiver] == snapshotAmount, "Snapshot mismatch");
// Proceed with fixed snapshot amount
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 25 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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