Bug 1 — mintSnowman() has no access control (anyone mints unlimited NFTs)
Snowman.mintSnowman(address, uint256) is external with no access-control modifier (no onlyOwner, no onlyAirdrop, no reference to the airdrop contract). The intended design is that only the SnowmanAirdrop contract mints Snowman NFTs as a reward for staked Snow — but nothing enforces that.
Attack: any EOA calls mintSnowman(attacker, 1_000_000) and instantly owns one million Snowman NFTs for free, with no Snow staked and no authorization. The entire NFT supply is unbounded and uncontrolled.
Bug 2 — claimSnowman() never checks the "claimed" flag and never burns Snow (re-claims + permanent lock)
SnowmanAirdrop.claimSnowman() sets s_hasClaimedSnowman[receiver] = true but never reads it, so the one-claim-per-user guard is dead. It also moves the receiver's whole Snow balance into the airdrop contract (comment: "akin to burning") but never burns it and never returns it, so the Snow is trapped forever.
Attack: a user calls claimSnowman() a second time. It succeeds again (the flag is never read), pulls their current Snow balance into the contract again (locked permanently), and mints more Snowman NFTs for whatever Snow they hold. Combined with Bug 1, an attacker can re-claim indefinitely and mint unlimited NFTs.
test_bug1 shows a stranger EOA mints one million NFTs with no Snow staked. test_bug2 shows the second claim succeeds (the s_hasClaimedSnowman flag is never read) and the user's Snow ends up locked in the airdrop contract.
Recommended Mitigation
Bug 1 — restrict mintSnowman to the airdrop contract (or the owner):
Bug 2 — check the claimed flag before claiming and actually burn the Snow:
mintSnowman Allows Arbitrary NFT MintingSeverity: High (subject to contest impact criteria)
Likelihood: High
Impact: High
Confidence: Confirmed
Affected contract: src/Snowman.sol
Affected function: mintSnowman(address receiver, uint256 amount)
Snowman.mintSnowman() is externally callable by any address and performs no caller authorization. Any account can therefore mint arbitrary numbers of Snowman NFTs to any recipient without holding Snow, staking tokens, presenting a Merkle proof, or obtaining an Airdrop signature. This bypasses the intended distribution path through SnowmanAirdrop and allows the NFT supply to be inflated without protocol authorization.
The function is declared external but has no access-control modifier or msg.sender check:
Although Snowman inherits Ownable, ownership is not enforced on this function. The README describes Snowman NFTs as rewards distributed to Snow stakers through SnowmanAirdrop, but this restriction is absent from the implementation.
An attacker obtains no Snow and does not interact with SnowmanAirdrop.
The attacker calls mintSnowman(attacker, N) directly on the deployed Snowman contract.
The call succeeds and mints N NFTs to the attacker.
The attacker can repeat the call. There is no protocol-enforced maximum supply or per-address cap.
The practical amount per transaction is limited by gas, but this does not prevent repeated unauthorized minting.
An arbitrary user can create NFTs outside the intended allocation mechanism. This compromises collection supply integrity and eligibility, and can dilute scarcity or any value/utility associated with legitimately distributed NFTs. High severity assumes that NFT scarcity, eligibility, or utility has material value. If the collection has no material economic value or privileged utility, a judge may downgrade the impact.
Add this test under test/ (or include it in the supplied PoC file):
Expected result: The test passes against the supplied source, demonstrating that an arbitrary EOA can mint NFTs directly.
Restrict minting to the deployed Airdrop contract. Because the current deployment flow deploys Snowman and SnowmanAirdrop as separate contracts, a one-time owner-configured Airdrop address avoids a constructor circularity:
The deployment procedure must call setAirdrop with the correct deployed Airdrop address before claims are enabled. An alternative is to transfer Snowman's ownership to the Airdrop contract and protect mintSnowman with onlyOwner, provided the deployment and ownership-transfer sequence is implemented correctly.
Severity: Medium
Likelihood: Medium
Impact: Medium
Confidence: Confirmed (replay path uses the repository's published fixture)
Affected contract: src/SnowmanAirdrop.sol
Affected function: claimSnowman(address,bytes32[],uint8,bytes32,bytes32)
SnowmanAirdrop maintains s_hasClaimedSnowman and sets it to true after a successful claim, but never checks the mapping before processing a later claim. A recipient can therefore consume the same Merkle allocation more than once if they obtain the same eligible Snow balance again.
This is practical in the supplied implementation. The project's Snow.earnSnow() function mints one additional unit after the global one-week timer expires. The Merkle leaf is based on (receiver, amount), and the EIP-712 digest is also based on the same receiver and current amount. If the recipient replenishes the exact amount in the leaf, the same Merkle proof and signature remain valid.
This finding concerns replay of a one-time allocation. The transfer of Snow into the Airdrop is described by the README as staking; this report does not claim that the transfer is necessarily unintended or that the tokens should be burned.
The mapping is declared and written but never read in claimSnowman:
There is no guard such as:
The claim signature has no nonce or claimed-state component. For an unchanged (receiver, amount), getMessageHash(receiver) returns the same digest. The Merkle leaf is also unchanged.
An eligible recipient has the Merkle-listed amount of Snow and approves the Airdrop.
The recipient signs getMessageHash(receiver) and submits the corresponding valid Merkle proof.
The first claim succeeds, transfers the Snow into the Airdrop, and mints the corresponding NFTs. The mapping is set to true.
The recipient waits until the global farming timer permits another earnSnow() call and replenishes the same amount.
The recipient approves the Airdrop again and reuses the same proof and signature.
Because claimSnowman never reads s_hasClaimedSnowman, the second claim succeeds and mints additional NFTs for the same allocation.
The attack is not an unconditional or instant unlimited mint: the recipient must replenish the Merkle-listed amount. The supplied weekly farming mechanism provides a way to replenish the one-unit allocation in the published fixture.
The same recipient can receive multiple NFT distributions for one Merkle allocation, undermining the apparent one-time claim invariant represented by s_hasClaimedSnowman. This can inflate NFT distribution beyond the allocation encoded in the Merkle tree. The impact is bounded by the recipient's ability to replenish the required Snow amount.
This test uses the Merkle root and Alice proof included in the repository's test/TestSnowmanAirdrop.t.sol. It uses the same deterministic alice account label as the project's helper and obtains a valid EIP-712 signature from Alice's corresponding test key.
Expected result: The test passes against the supplied source. It demonstrates two successful claims for the same (receiver, amount) Merkle allocation, with the same proof and signature.
Check the claim state and set it before external token/NFT interactions (checks-effects-interactions):
A revert in any later operation rolls back the state update. Preserve the project's staking behavior unless the protocol specification separately requires a burn or withdrawal mechanism.
# 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.