Snowman Merkle Airdrop

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

Forced Token Burning Without User Consent in SnowmanAirdrop.sol

Root + Impact

Description

  • In a typical airdrop system, users should have control over how many tokens they want to exchange for NFTs, allowing them to specify the amount they wish to burn while retaining the remainder of their token balance for other purposes.

  • The claimSnowman() function automatically burns the user's entire Snow token balance without providing any mechanism for users to specify a partial amount. The contract retrieves the user's full balance via i_snow.balanceOf(receiver) and transfers all tokens to itself, forcing users to surrender complete control over their assets to claim any NFTs.

function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s)
external
nonReentrant
{
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
if (i_snow.balanceOf(receiver) == 0) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
@> uint256 amount = i_snow.balanceOf(receiver); // Always uses FULL balance
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
@> i_snow.safeTransferFrom(receiver, address(this), amount); // Burns ALL tokens
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
@> i_snowman.mintSnowman(receiver, amount); // Mints NFTs equal to ALL burned tokens
}

Risk

Likelihood:

  • This vulnerability triggers on every single airdrop claim without exception, as the claimSnowman() function always executes the vulnerable code path that burns the user's entire token balance.

  • Users have no alternative claiming mechanism or workaround available - the contract provides only one function to claim NFTs, and it mandatorily consumes all tokens regardless of user intent or preference.

Impact:

  • Users lose complete control over their token allocation strategy, being forced to burn their entire Snow token balance even when they may want to retain tokens for future utility, trading, or other protocol interactions.

  • The contract creates an economic disincentive for users with valuable token positions to participate in the airdrop, as they must choose between claiming NFTs or maintaining their token holdings, potentially reducing overall protocol engagement and user satisfaction.

Proof of Concept

contract ForcedTokenBurningPoC is Test {
Snow public snow;
Snowman public snowman;
SnowmanAirdrop public airdrop;
MockWETH public weth;
address public collector = makeAddr("collector");
address public alice;
uint256 public aliceKey;
uint256 public constant BUY_FEE = 1;
bytes32 public constant MERKLE_ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
// Alice's merkle proof (from test file)
bytes32[] public ALICE_PROOF;
function setUp() public {
// Create Alice with key for signing
(alice, aliceKey) = makeAddrAndKey("alice");
// Deploy contracts
weth = new MockWETH();
snow = new Snow(address(weth), BUY_FEE, collector);
string memory svgUri = "data:image/svg+xml;base64,PHN2Zy4uLi4+";
snowman = new Snowman(svgUri);
airdrop = new SnowmanAirdrop(MERKLE_ROOT, address(snow), address(snowman));
// Initialize Alice's proof array
ALICE_PROOF.push(0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52);
ALICE_PROOF.push(0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af);
ALICE_PROOF.push(0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1);
// Give Alice some ETH to buy Snow tokens
deal(alice, 10 ether);
}
/**
* @notice CORE VULNERABILITY DEMONSTRATION
* @dev Proves that users are forced to burn their entire token balance
*/
function testForcedTokenBurningVulnerability() public {
console2.log("=== FORCED TOKEN BURNING VULNERABILITY ===");
// === PHASE 1: Alice gets exactly 1 Snow token (to match merkle tree) ===
console2.log("\n--- Phase 1: Alice Gets 1 Token (Merkle Tree Requirement) ---");
// Alice earns her Snow token (this gives her exactly 1 token as per merkle tree)
vm.prank(alice);
snow.earnSnow();
uint256 aliceBalance = snow.balanceOf(alice);
console2.log("Alice's Snow balance:", aliceBalance);
assertEq(aliceBalance, 1, "Alice should have 1 Snow token for merkle proof");
// === PHASE 2: Demonstrate forced burning - Alice MUST burn her token ===
console2.log("\n--- Phase 2: Forced Token Burning Demonstration ---");
console2.log("SCENARIO: Alice wants to keep her Snow token but also get NFT");
console2.log("REALITY: Contract will force her to burn her token to get NFT");
console2.log("ISSUE: No option to claim without burning tokens");
// Alice approves the contract (she has no choice but to approve her full balance)
vm.prank(alice);
snow.approve(address(airdrop), aliceBalance);
// Get Alice's signature for the claim
bytes32 messageHash = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, messageHash);
// Record balances before claim
uint256 aliceBalanceBefore = snow.balanceOf(alice);
uint256 contractBalanceBefore = snow.balanceOf(address(airdrop));
uint256 aliceNFTsBefore = snowman.balanceOf(alice);
console2.log("Before claim:");
console2.log(" Alice Snow balance:", aliceBalanceBefore);
console2.log(" Contract Snow balance:", contractBalanceBefore);
console2.log(" Alice NFT balance:", aliceNFTsBefore);
// === THE VULNERABILITY: Contract burns ALL tokens without user consent ===
vm.prank(alice);
airdrop.claimSnowman(alice, ALICE_PROOF, v, r, s);
// Record balances after claim
uint256 aliceBalanceAfter = snow.balanceOf(alice);
uint256 contractBalanceAfter = snow.balanceOf(address(airdrop));
uint256 aliceNFTsAfter = snowman.balanceOf(alice);
console2.log("\nAfter claim:");
console2.log(" Alice Snow balance:", aliceBalanceAfter);
console2.log(" Contract Snow balance:", contractBalanceAfter);
console2.log(" Alice NFT balance:", aliceNFTsAfter);
// === VULNERABILITY VERIFIED ===
console2.log("\n=== VULNERABILITY PROVEN ===");
console2.log("CRITICAL: Alice was FORCED to burn her", aliceBalanceBefore, "Snow token(s)!");
console2.log("CRITICAL: No way to claim NFT without burning tokens!");
console2.log("CRITICAL: Users have ZERO choice in token consumption!");
// Assertions proving the vulnerability
assertEq(aliceBalanceAfter, 0, "Alice should have 0 Snow tokens left");
assertEq(contractBalanceAfter, aliceBalanceBefore, "Contract should hold all burned tokens");
assertEq(aliceNFTsAfter, aliceBalanceBefore, "Alice gets NFTs equal to ALL burned tokens");
console2.log("\nVULNERABILITY IMPACT:");
console2.log("1. Users cannot keep ANY Snow tokens after claiming");
console2.log("2. No partial claim functionality");
console2.log("3. Contract forces 100% token burning");
console2.log("4. Complete loss of user autonomy over assets");
console2.log("SEVERITY: MEDIUM - Forced asset consumption without consent");
}

PoC Result:

Ran 1 test for test/ForcedTokenBurningPoC.t.sol:ForcedTokenBurningPoC
[PASS] testForcedTokenBurningVulnerability() (gas: 259776)
Logs:
=== FORCED TOKEN BURNING VULNERABILITY ===
--- Phase 1: Alice Gets 1 Token (Merkle Tree Requirement) ---
Alice's Snow balance: 1
--- Phase 2: Forced Token Burning Demonstration ---
SCENARIO: Alice wants to keep her Snow token but also get NFT
REALITY: Contract will force her to burn her token to get NFT
ISSUE: No option to claim without burning tokens
Before claim:
Alice Snow balance: 1
Contract Snow balance: 0
Alice NFT balance: 0
After claim:
Alice Snow balance: 0
Contract Snow balance: 1
Alice NFT balance: 1
=== VULNERABILITY PROVEN ===
CRITICAL: Alice was FORCED to burn her 1 Snow token(s)!
CRITICAL: No way to claim NFT without burning tokens!
CRITICAL: Users have ZERO choice in token consumption!
VULNERABILITY IMPACT:
1. Users cannot keep ANY Snow tokens after claiming
2. No partial claim functionality
3. Contract forces 100% token burning
4. Complete loss of user autonomy over assets
SEVERITY: MEDIUM - Forced asset consumption without consent
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 30.95ms (4.13ms CPU time)
Ran 1 test suite in 104.69ms (30.95ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Modify the claimSnowman() function to accept an amount parameter, allowing users to specify how many tokens they want to burn:

function claimSnowman(
address receiver,
+ uint256 amount,
bytes32[] calldata merkleProof,
uint8 v,
bytes32 r,
bytes32 s
) external nonReentrant {
if (receiver == address(0)) {
revert SA__ZeroAddress();
}
- if (i_snow.balanceOf(receiver) == 0) {
+ if (amount == 0 || amount > i_snow.balanceOf(receiver)) {
revert SA__ZeroAmount();
}
if (!_isValidSignature(receiver, getMessageHash(receiver), v, r, s)) {
revert SA__InvalidSignature();
}
- uint256 amount = i_snow.balanceOf(receiver);
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(receiver, amount))));
if (!MerkleProof.verify(merkleProof, i_merkleRoot, leaf)) {
revert SA__InvalidProof();
}
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge 5 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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