Snowman.mintSnowman directly, so NFT supply is unlimited, free, and no longer backed by staked SnowThe README makes Snow the thing that earns the NFT. The Snow token "automatically makes one eligible to claim a Snowman NFT" (README.md:15), and "the staker receives Snowman NFTs in the value of how many Snow tokens they own" (README.md:17). SnowmanAirdrop.claimSnowman enforces this with three gates before it mints: a non-zero Snow balance, a signature from the receiver, and a Merkle leaf for (receiver, balance) (src/SnowmanAirdrop.sol:76-90). It then locks the receiver's Snow and calls the NFT contract (src/SnowmanAirdrop.sol:92-98).
But the function it calls has no caller check, no role and no cap. Any address can call it directly, for any receiver and any amount, with no Snow, proof, signature or payment. It mints through the same _safeMint and emits the same event as a claim, so a free NFT is indistinguishable from a claimed one. Snowman declares SM__NotAllowed, but never throws it, so nothing restricts who may mint. The airdrop's gates guard only the one route that happens to call this function.
It works on a fresh deployment or after claims, and after the 12-week farming window ends, when no more Snow can ever be made (test_F001_mintStaysOpenAfterSnowFarmingEnds below). Transferring ownership to the airdrop or renouncing it doesn't close it, because no function checks ownership (test_F001_ownerCantCloseTheMintByHandingOwnershipToTheAirdrop, test_F001_ownerCantCloseTheMintByRenouncing), and nothing in Snowman is upgradeable.
On intent: the README never says "only the airdrop may mint" in so many words, and test/TestSnowman.t.sol mints from an unprivileged address. But if open minting were intended, the airdrop's stake, signature and Merkle gates would guard nothing, and "eligible to claim" would mean nothing. SM__NotAllowed is declared in Snowman and never thrown, which reads as a forgotten guard. CodeHawks' validity guide lists "design opinions with no clear loss of funds indication" among findings that may be invalid. This isn't a design opinion: it's the airdrop's whole control on supply being bypassed, measured in the PoC below.
Likelihood: High. Anyone can call mintSnowman, at any time and from any state, for the cost of gas. It needs no role, balance, leaf, signature or ETH, and there's no end date.
Impact: High. The distribution the protocol exists to run no longer controls supply, which is a severe disruption of its core function. In the PoC, after a holder pays 5 ETH for one NFT through the airdrop, an address with no ETH or Snow mints 100 more (Foundry doesn't charge a pranked sender for gas; a real caller pays only gas, and nothing to the protocol), leaving 99% of the supply with nothing staked behind it. On the repo's build each free NFT costs 33,430 gas, and repeated calls have no cap: one address reaches 1,301 NFTs (test_F001_gasPerFreeNftAndNoCumulativeCap below). The free NFTs share the same contract, metadata and SnowmanMinted event as claimed ones. No funds are taken directly, and free mints don't block a later legitimate claim (test_F001_control_freeMintsDontBlockALaterClaim).
Save the file below as test/F001OpenMint.t.sol in the contest repository and run, from the repository root (no fork needed):
It imports only the repository's own src/ and script/ files and forge-std. It deploys the system in its own setUp with the repo's DeploySnowmanAirdrop script, so all the contracts are the real ones, and nothing is mocked or stubbed. The missing check is in Snowman.sol itself, so the result doesn't depend on the OpenZeppelin version; the gas figures use the repo's own build settings (via-IR, optimizer off). Actors:
Attacker: mallory, a fresh address with no role, balance or leaf.
Victim: alice, a listed holder who pays the full fee on the gated route, and the holders whose NFTs were meant to be backed by staked Snow.
Protocol: Snowman, Snow and SnowmanAirdrop.
Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:
Before the free mint there is 1 NFT, backed by 1 locked Snow, and Snow holds the 5 ETH Alice paid. After it there are 101 NFTs, still only 1 Snow locked, with no new Snow and no new ETH, and the attacker holds 100 NFTs it paid nothing for. A free NFT has the same tokenURI as the claimed one and transfers the same way.
The control, test_F001_control_gatesStopTheSameOutsiderOnTheAirdropRoute, shows the airdrop's gates work on their own route. The same outsider is refused with no Snow, with no valid signature, and with no leaf. It then gets the NFT directly from mintSnowman and keeps its Snow.
The other tests give the figures in the Risk section. test_F001_gasPerFreeNftAndNoCumulativeCap measures 3,343,070 gas for 100 free NFTs, 33,430 each, and reaches 1,301 NFTs for one address with nothing locked behind any of them. The mint stays open after farming ends and ten years later, with no Snow ever having existed, and after ownership is handed to the airdrop or renounced. A later legitimate claim still works and just takes the next token ID. The rough count of NFTs per transaction in the log uses EIP-7825's 16,777,216 gas cap, which came into force after the contest, so it's context only.
Restrict mintSnowman to the airdrop, using the error the contract already declares. Snowman is deployed before the airdrop, whose constructor takes Snowman's address, so the owner sets the airdrop once and the setter can't be called again:
Notes:
The setter uses the Ownable that Snowman already inherits, so only the owner can set the airdrop.
Before the airdrop is set, nobody can mint, the owner included. Making mintSnowman onlyOwner and transferring ownership to the airdrop would also close the mint, but it's weaker, because the deployer could mint before handing ownership over.
Deploy script: update DeploySnowmanAirdrop to call the setter. Snowman's owner is the broadcasting sender, so that call needs its own broadcast. Update test/TestSnowman.t.sol to mint through the airdrop.
Tested: this exact change was checked on a patched copy of Snowman. The attack reverts with SM__NotAllowed, and all five listed accounts still earn and claim.
Trade-off: the setting is permanent, so Snowman is tied to one airdrop, and any later airdrop change means redeploying both contracts.
# 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.