Snowman.mintSnowman (src/Snowman.sol:36-44) is an external function with no modifier and no msg.sender check in the function body. The loop at src/Snowman.sol:37-43 unconditionally calls _safeMint(receiver, s_TokenCounter), emits the SnowmanMinted event, and increments the shared counter, so the admission condition for minting is entirely determined by the caller.
The constructor (src/Snowman.sol:30-33) only writes s_TokenCounter = 0 and s_SnowmanSvgUri; it does not record a minter address, allowlist, or role. Although the contract inherits Ownable (src/Snowman.sol:17), ownership is never consulted on the minting path (onlyOwner / _checkOwner does not appear in src/*.sol).
The root cause is that the mint entry point lacks an authorization gate (caller validation or role constraint). This is supported by three code facts:
src/Snowman.sol:36: function mintSnowman(address receiver, uint256 amount) external — no modifier, no msg.sender check, no allowlist mapping lookup.
src/Snowman.sol:20 declares error SM__NotAllowed();, but grep 'SM__NotAllowed' returns only the declaration line — the error is never used for revert. This is consistent with "an access check was once planned but is missing" (inference, not conclusion).
Neither the constructor at src/Snowman.sol:30-33 nor the airdrop constructor at src/SnowmanAirdrop.sol:55-66 establishes two-way wiring: Snowman does not know the airdrop address, and the airdrop only stores i_snowman; minting permission was never closed in the design.
grep 'mintSnowman' returns only three call sites: the definition at src/Snowman.sol:36, the in-protocol call at src/SnowmanAirdrop.sol:98, and a direct call in tests at test/TestSnowman.t.sol:29-30. There is no access control between external callers and _safeMint.
As a result, all three preconditions designed in SnowmanAirdrop.claimSnowman (src/SnowmanAirdrop.sol:69-99) can be ignored on the minting path: the Snow balance check (src/SnowmanAirdrop.sol:76-78), the EIP-712 signature verification (src/SnowmanAirdrop.sol:80-82), and the Merkle proof verification (src/SnowmanAirdrop.sol:84-90). The airdrop merely calls i_snowman.mintSnowman(receiver, amount) at src/SnowmanAirdrop.sol:98, and the Snowman side does not verify that the caller is the airdrop contract, so any EOA can directly obtain the same result without Snow, without a proof, and without a signature.
Who can trigger it. Any EOA. It does not need to hold Snow, does not need a Merkle proof, does not need an EIP-712 signature, does not need the Snowman owner role, and does not need to call SnowmanAirdrop.
What preconditions are required. Only that Snowman has been deployed (whether via the standalone deployment script/DeploySnowman.s.sol or the linked deployment with the airdrop script/DeploySnowmanAirdrop.s.sol). There is no supply cap, no per-address quota, and no pause switch, so there is no available "blocking" state.
Specific impact on the protocol and user assets (confirmed by the executed PoC record).
An ordinary EOA explicitly asserted not to be the Snowman owner and to have a Snow balance of 0 mints 100 SNOWMAN through mintSnowman in a single transaction; another unrelated EOA mints 50 more to a third-party address; the first EOA can continue minting. The attacker pays no fee: after minting, snow.balanceOf(attacker) == 0, snow.balanceOf(address(airdrop)) == 0, and airdrop.getClaimStatus(attacker) == false, meaning the staking transfer at src/SnowmanAirdrop.sol:92 never occurs.
The shared counter s_TokenCounter is advanced by the attacker's mints, so an honest claimer's NFT receives tokenId = 151 instead of the first token. The honest claimSnowman path itself still succeeds (its amount and Merkle leaf depend only on the receiver's Snow balance, src/SnowmanAirdrop.sol:84-90), so the airdrop is not blocked, but it is diluted and renumbered.
In plain terms: the SnowmanAirdrop flow (Merkle allowlist + EIP-712 signature + Snow transfer) is no longer a prerequisite for holding SNOWMAN; NFT supply is unlimited and free for any address, and the tokenId space used by real claimers is polluted.
Unconfirmed impact boundary: this does not include loss of staked Snow or WETH. Pre-minting to a whitelisted address does not change the amount that address can claim (src/SnowmanAirdrop.sol:84-90), so statements such as "the airdrop quota is completely bypassed" can only be understood narrowly as "free minting + tokenId pollution." No market value, supply cap, or airdrop allocation size was found in the repository, so the proposed high above remains uncalibrated.
Test path: test/w3aas/F-0008.t.sol (contract F_0008_Test, test test_F_0008)
Run command: forge test --match-path test/w3aas/F-0008.t.sol --match-test test_F_0008 -vvv
Run result: the failure log logs/F-0008-1.log records exit_code=0, 1 passed; 0 failed (this log path is subject to the evidence verification note at the top and requires manual confirmation).
Setup (test/w3aas/F-0008.t.sol:50-72) assembles MockWETH → Snow → Snowman → SnowmanAirdrop in production form. The only Snow in the system is obtained by the "legitimate control claimer" through the project's own permissionless Snow.earnSnow(); the attacker is an ordinary EOA generated by makeAddr and is given no funds. Precondition assertions also confirm that the attacker is not the Snowman owner, that the attacker's Snow and SNOWMAN balances are both 0, and that the counter starts at 0 (test/w3aas/F-0008.t.sol:87-91).
Key test code (excerpt from the file; line numbers correspond to test/w3aas/F-0008.t.sol):
Key log lines (quoted from the original report's logs/F-0008-1.log):
Assertion-by-assertion explanation:
Control group (test/w3aas/F-0008.t.sol:94-104): the attacker's call to claimSnowman reverts (SA__ZeroAmount, src/SnowmanAirdrop.sol:76-78, shown as selector 0x4c11d20c in the log), and assertEq(nft.getTokenCounter(), 0) shows that this rejected call minted nothing. In other words, the designed restricted path is indeed closed to the attacker.
STEP 1 (:109-121): vm.prank(attacker); nft.mintSnowman(attacker, 100) succeeds; assertEq(nft.balanceOf(attacker), 100), assertEq(nft.ownerOf(0), attacker), and assertEq(nft.ownerOf(99), attacker) show that all 100 tokenIds were minted out of thin air to a permissionless, assetless address.
No cost paid / airdrop not used (:124-126): snow.balanceOf(attacker) == 0, getClaimStatus(attacker) == false, and snow.balanceOf(address(airdrop)) == 0 show that the staking transfer at src/SnowmanAirdrop.sol:92 did not occur and the airdrop flow was completely bypassed.
Third party can mint to any address (:129-133): a second EOA mints 50 to 0xBEEF while holding 0 Snow; getTokenCounter() == 150 shows that the shared counter was advanced only by these free mints.
Repeatable (:136-138): the same EOA mints again, and its balance becomes 101, showing that there is no per-address quota, total supply cap, or pause protection.
Pollution of the honest path (:144-164): with a valid signature (vm.sign) and a single-leaf empty proof, the legitimate claimer's claimSnowman still succeeds (balanceOf(legit) == 1), but legitTokenId == counterBeforeClaim == 151, ownerOf(151) == legit, and assertTrue(legitTokenId != 0) show that the real claimer's tokenId has been shifted by the attacker's mints; getTokenCounter() == 152 shows that the counter continues to grow on top of the free mints.
Repository cross-check: test/TestSnowman.t.sol:28-40 (testMintSnowman) directly calls nft.mintSnowman(alice, 1) / (bob, 2) as the test contract, without a signature or staking, and asserts success and getTokenCounter() == 3 — an existing test in the repository treats mintSnowman as a publicly callable function, consistent with the conclusion above.
Fix approach: the root cause is that "the mint entry point does not validate the caller's identity," so the fix must be at the entry point of Snowman.mintSnowman, not by adding more checks on the SnowmanAirdrop side. The most direct solution is to fix the only legitimate minter in Snowman (the airdrop contract address, stored with immutable to avoid introducing a mutable privileged role), compare msg.sender on the first line of the function, and reuse the already-declared but never-used SM__NotAllowed (src/Snowman.sol:20) when it does not match. This eliminates the root cause, because the Snow balance check, EIP-712 signature verification, and Merkle proof verification in SnowmanAirdrop.claimSnowman (src/SnowmanAirdrop.sol:76-90) become necessary conditions for minting again: external EOAs can no longer directly mint NFTs to arbitrary addresses, the shared counter can only be advanced by legitimate claims, and tokenId pollution disappears. Note the structural impact: the SnowmanAirdrop constructor requires a Snowman address (src/SnowmanAirdrop.sol:55-66), and conversely Snowman also needs the airdrop address, forming a circular deployment-order dependency. Therefore, either pass the address in a deployment flow where the airdrop address is predictable, or provide a one-time onlyOwner setter that can be set only once and cannot be changed after being set; in either case, after setup is complete, no one must be able to change it. If the product intent is indeed public minting, then the misleading access-control signals (SM__NotAllowed, Ownable inheritance) should be removed, the public minting semantics should be clearly documented, and a supply cap and rate limits should be set so that the airdrop's "1 Snow : 1 NFT" economic relationship cannot be easily bypassed.
Illustrative patch (not repository state; only shows validation location and minimal change):
Illustrative regression tests (not run in this repository; testMintSnowman in test/TestSnowman.t.sol:28-40, which depends on public minting, must also be updated for the new signature):
Additional note (uncertain items, not conclusions): this patch and regression tests are only illustrative of the fix direction; this revision did not write tests or run tests; how the deployment order of Snowman and SnowmanAirdrop should be adjusted, whether the production environment uses the linked form in script/DeploySnowmanAirdrop.s.sol, and whether the Snowman owner would execute the fix have no basis in the repository or contest data (no trusted_roles, no known_issues), so the feasibility of the fix still requires manual confirmation.
# 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.