Snowman Merkle Airdrop

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

Unrestricted Snowman NFT Minting

Root + Impact

Description

  • The intended behavior is that users receive Snowman NFTs only through the SnowmanAirdrop contract. In the normal flow, a user must hold Snow, provide a valid Merkle proof, provide a valid EIP-712 signature, approve the airdrop contract to transfer their Snow, and then the airdrop contract mints NFTs equal to the user’s eligible amount.


  • The issue is that the NFT minting function on Snowman is publicly callable by anyone. There is no access control restricting mintSnowman to the airdrop contract, the owner, or an approved minter role. As a result, an attacker can skip the entire airdrop claim process and mint arbitrary NFTs directly.

// Root cause in the codebase with @> marks to highlight the relevant section
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
@> _safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Risk

Likelihood:

  • The vulnerable function is external, so any address can call it directly.

  • There is no access control check such as onlyOwner, onlyAirdrop, or onlyRole.

  • The attacker does not need Merkle proof data, a valid signature, Snow tokens, or token approval.

Impact:

  • The airdrop eligibility system can be fully bypassed.

  • NFT supply can be inflated arbitrarily, breaking scarcity and token economics.

  • Attackers can mint NFTs to themselves or to other addresses without consent.

  • The s_TokenCounter supply accounting becomes meaningless because it can be increased by unauthorized users.


Proof of Concept

The following test demonstrates that an address which is not the contract owner can mint NFTs directly. The attacker calls mintSnowman without interacting with SnowmanAirdrop, without owning or staking Snow, and without providing a Merkle proof or signature.

function testExploit_AnyoneCanMintNFTs() public {
address attacker = makeAddr("attacker");
assertEq(nft.balanceOf(attacker), 0);
vm.prank(attacker);
nft.mintSnowman(attacker, 100);
assertEq(nft.balanceOf(attacker), 100);
assertEq(nft.getTokenCounter(), 100);
}

This succeeds because mintSnowman does not validate msg.sender. The attacker can repeat the same call with larger amounts until gas limits prevent a single transaction, and can continue across multiple transactions.


Recommended Mitigation

Restrict minting so that only the authorized airdrop contract can mint NFTs. This preserves the intended claim flow: Merkle proof validation, signature validation, Snow transfer, claim-state update, and then NFT minting.

+ error SM__NotAllowed();
+
+ address private immutable i_airdrop;
+
+ modifier onlyAirdrop() {
+ if (msg.sender != i_airdrop) revert SM__NotAllowed();
+ _;
+ }
- constructor(string memory _SnowmanSvgUri) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
+ constructor(string memory _SnowmanSvgUri, address _airdrop) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
+ if (_airdrop == address(0)) revert SM__NotAllowed();
+ i_airdrop = _airdrop;
s_TokenCounter = 0;
s_SnowmanSvgUri = _SnowmanSvgUri;
}
- function mintSnowman(address receiver, uint256 amount) external {
+ function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 4 days 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!