Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: high
Valid

Unrestricted Snowman.mintSnowman Lets Any Address Mint Unlimited SNOWMAN, Bypassing the SnowmanAirdrop Claim Gate

Description

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.

Risk

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.

Proof of Concept

  • 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):

// test/w3aas/F-0008.t.sol:109-138 — permissionless EOA directly calls mintSnowman
uint256 gasBefore = gasleft();
vm.prank(attacker);
nft.mintSnowman(attacker, ATTACK_QTY);
uint256 gasUsed = gasBefore - gasleft();
​
console2.log("STEP1 mint gas used:", gasUsed);
console2.log("STEP1 attacker SNOWMAN balance:", nft.balanceOf(attacker));
console2.log("STEP1 tokenCounter:", nft.getTokenCounter());
​
// expected_impact: unprivileged free mint succeeded.
assertEq(nft.balanceOf(attacker), ATTACK_QTY, "HYPOTHESIS REFUTED: direct mintSnowman did not mint to attacker");
assertEq(nft.ownerOf(0), attacker, "impact: attacker owns tokenId 0 without any airdrop claim");
assertEq(nft.ownerOf(ATTACK_QTY - 1), attacker, "impact: all 100 tokenIds minted to attacker");
​
// Nothing was paid / staked and no claim was recorded: the airdrop flow was bypassed.
assertEq(snow.balanceOf(attacker), 0, "impact: attacker paid no Snow (no staking at src/SnowmanAirdrop.sol:92)");
assertEq(airdrop.getClaimStatus(attacker), false, "impact: attacker never went through claimSnowman");
assertEq(snow.balanceOf(address(airdrop)), 0, "impact: airdrop contract received no Snow from attacker");
​
// Anybody can do it: a second unrelated EOA mints to an arbitrary third address.
vm.prank(attacker2);
nft.mintSnowman(address(0xBEEF), ATTACK2_QTY);
assertEq(nft.balanceOf(address(0xBEEF)), ATTACK2_QTY, "impact: third party can mint to any receiver, 0 Snow held");
assertEq(snow.balanceOf(attacker2), 0, "impact: second minter also paid nothing");
assertEq(nft.getTokenCounter(), ATTACK_QTY + ATTACK2_QTY, "impact: counter advanced only by attacker mints");
​
// Repeatability (no per-address cap, no total cap, no pause/unpause guard).
vm.prank(attacker);
nft.mintSnowman(attacker, 1);
assertEq(nft.balanceOf(attacker), ATTACK_QTY + 1, "impact: same EOA can keep minting");
// test/w3aas/F-0008.t.sol:144-164 — honest claim still succeeds, but tokenId is polluted by attacker mints
bytes32 digest = airdrop.getMessageHash(legit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(legitKey, digest);
bytes32[] memory proof = new bytes32[](0); // single-leaf tree: empty proof
​
vm.prank(legit);
snow.approve(address(airdrop), type(uint256).max);
​
uint256 counterBeforeClaim = nft.getTokenCounter();
assertEq(counterBeforeClaim, ATTACK_QTY + ATTACK2_QTY + 1, "harness: counter before legit claim");
​
vm.prank(legit);
airdrop.claimSnowman(legit, proof, v, r, s);
​
uint256 legitTokenId = counterBeforeClaim;
console2.log("legit claimer received tokenId:", legitTokenId);
console2.log("tokenCounter after legit claim:", nft.getTokenCounter());
​
assertEq(nft.balanceOf(legit), 1, "harness/control: the honest claimer path still works");
assertEq(nft.ownerOf(legitTokenId), legit, "impact: legit claimer's tokenId is shifted by attacker mints, not 0");
assertTrue(legitTokenId != 0, "impact: attacker mints consumed tokenId 0..N (pollution)");
assertEq(nft.getTokenCounter(), counterBeforeClaim + 1, "impact: shared counter keeps growing on top of free mints");

Key log lines (quoted from the original report's logs/F-0008-1.log):

control: attacker claimSnowman() reverted, selector: 0x4c11d20c
STEP1 mint gas used: 3389445
STEP1 attacker SNOWMAN balance: 100
STEP1 tokenCounter: 100
legit claimer received tokenId: 151
tokenCounter after legit claim: 152

Assertion-by-assertion explanation:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Recommended Mitigation

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):

// src/Snowman.sol (illustrative patch)
contract Snowman is ERC721, Ownable {
error ERC721Metadata__URI_QueryFor_NonExistentToken();
error SM__NotAllowed(); // Already declared at src/Snowman.sol:20; actually used starting here
​
address private immutable i_airdrop; // the only legitimate minter
​
constructor(string memory _SnowmanSvgUri, address airdrop) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
if (airdrop == address(0)) revert SM__NotAllowed(); // or reuse the project's existing zero-address error
i_airdrop = airdrop;
s_TokenCounter = 0;
s_SnowmanSvgUri = _SnowmanSvgUri;
}
​
function mintSnowman(address receiver, uint256 amount) external {
if (msg.sender != i_airdrop) revert SM__NotAllowed(); // Root-cause fix: entry-point authorization
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
}
// If the airdrop address cannot be obtained at construction time, use a one-time setter (illustrative; must succeed only once)
+ address private s_airdrop;
+ bool private s_airdropSet;
+
+ function setAirdrop(address airdrop) external onlyOwner {
+ if (s_airdropSet) revert SM__NotAllowed();
+ s_airdrop = airdrop;
+ s_airdropSet = true;
+ }
​
function mintSnowman(address receiver, uint256 amount) external {
+ if (!s_airdropSet || msg.sender != s_airdrop) revert SM__NotAllowed();
for (uint256 i = 0; i < amount; i++) { /* ... original logic unchanged ... */ }
}

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):

// Negative: non-airdrop caller must be rejected
function test_mintRevertsForNonAirdropCaller() public {
address eve = makeAddr("eve");
vm.prank(eve);
vm.expectRevert(Snowman.SM__NotAllowed.selector);
nft.mintSnowman(eve, 1);
assertEq(nft.balanceOf(eve), 0);
assertEq(nft.getTokenCounter(), 0);
}
​
// Positive: legitimate claim path still works and receives unpolluted tokenId 0
function test_airdropClaimStillMintsFirstToken() public {
bytes32 digest = airdrop.getMessageHash(legit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(legitKey, digest);
bytes32[] memory proof = new bytes32[](0);
​
vm.prank(legit);
snow.approve(address(airdrop), type(uint256).max);
vm.prank(legit);
airdrop.claimSnowman(legit, proof, v, r, s);
​
assertEq(nft.balanceOf(legit), 1);
assertEq(nft.ownerOf(0), legit); // no longer shifted by free mints
assertEq(nft.getTokenCounter(), 1);
}

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.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-01] Unrestricted NFT Minting in Snowman.sol

# 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++; } } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!