Snowman::mintSnowman has no access control. Impact: anyone mints unlimited Snowman NFTs for free, so the Merkle airdrop distributes nothing of valueSnowman NFTs are meant to be created only by the SnowmanAirdrop contract inside claimSnowman, and only after the caller has proven eligibility with a Merkle proof, supplied a valid signature, and surrendered their Snow tokens to the airdrop. That staking path is the protocol's only intended way to obtain the NFT.
Snowman::mintSnowman is declared external with no access control whatsoever. Any address calls it directly and mints any number of NFTs to itself while holding zero Snow and without appearing in the Merkle tree, bypassing the proof check, the signature check and the staking step completely.
That the guard was intended and simply forgotten is visible in the same file: Snowman inherits Ownable and sets an owner in the constructor that is then never used anywhere, and it declares error SM__NotAllowed(); which is never thrown.
Likelihood:
The function is unguarded from the moment the collection is deployed, so any address calls it in any block, with no precondition to satisfy and nothing to wait for.
It requires no Snow balance, no Merkle inclusion and no signature, so every address on the network qualifies permanently, for the whole life of the contract.
Impact:
The NFT supply is fully controlled by whoever calls the mint, so the airdrop hands out an asset anybody can create for free, and honest users who farmed Snow for weeks and burned it through the airdrop receive nothing scarce.
Token ids are consumed by whoever calls first, letting an attacker front run the real distribution and take the low ids, and no owner action stops or reverses it since the contract exposes no pause, no burn and no way to restrict minting.
An address that has never held a Snow token, is absent from the Merkle tree and has never signed anything calls Snowman::mintSnowman directly and receives 1000 NFTs. The entire eligibility mechanism is skipped, because the function never checks who is calling it.
Attacker: any externally owned account. Needs nothing at all: no Snow balance, no place in the Merkle snapshot, no signature, no approval.
Victim: the legitimate airdrop recipients from script/flakes/input.json, who farmed Snow across the 12 week FARMING_DURATION and must burn it through the airdrop to receive a Snowman that the attacker gets for free.
Protocol: Snowman (the ERC721 being minted) and SnowmanAirdrop, which is supposed to be the sole minter and is bypassed entirely.
Add to the existing test suite and run with forge test --match-test test_H1_AnyoneCanMintUnlimitedSnowman -vv. It is deployed through the project's own Helper.s.sol, so nothing is mocked.
Output:
Initial state: Snowman is deployed by DeploySnowman.s.sol, SnowmanAirdrop holds the Merkle root committing to five eligible addresses with 1 Snow each, and s_TokenCounter is 0.
Step 1: The attacker calls Snowman::mintSnowman(attacker, 1000) directly. There is no modifier on the function, so execution enters the loop immediately.
Step 2: The loop runs _safeMint 1000 times, incrementing s_TokenCounter each pass. Token ids 0 through 999 are assigned to the attacker.
Outcome: The attacker owns 1000 Snowman NFTs, having spent nothing but gas, while the airdrop contract has distributed none.
Implications: The legitimate claimants find the low token ids already taken, and the collection they earned is one that anybody could have minted for free.
The property the README promises, that every Snowman is backed by Snow somebody staked, was written as an invariant and fuzzed with random call sequences over buySnow, earnSnow, mintSnowman, claims and transfers. Foundry broke it without being told what to look for, and shrank the counterexample to a single call:
Restrict minting to the airdrop contract. The airdrop address is not known when Snowman is deployed, so add a one time owner guarded setter and gate the mint with the error that is already declared:
# 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++; } } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.