Snowman Merkle Airdrop

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

Snowman::mintSnowman is meant to be called only by the SnowmanAirdrop contract, after a valid Merkle proof and signature, to reward eligible Snow stakers.

Root + Impact

Description

function mintSnowman(address receiver, uint256 amount) external {
@> // no access control — any caller is accepted
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
s_TokenCounter++;
}
}Snowman::mintSnowman is meant to be called only by the SnowmanAirdrop contract, after a valid Merkle proof and signature, to reward eligible Snow stakers.
The function is external with no access control, so any address can # Security Review — Snowman Merkle Airdrop
​
**Auditor:** nullstar (SchaeferM04-hub)
**Type:** Retrospective practice review (completed CodeHawks contest `2025-06-snowman-merkle-airdrop`)
**Method:** Manual review + Foundry proof-of-concept tests
**Scope:** `src/Snow.sol`, `src/Snowman.sol`, `src/SnowmanAirdrop.sol`
**Compiler:** Solidity `^0.8.24`
​
---
​
## Summary
​
The Snowman Merkle Airdrop lets holders of the `Snow` ERC20 stake their tokens and
receive `Snowman` ERC721 NFTs, gated by a Merkle proof and an EIP-712 signature
(including claim-on-behalf). Review found the airdrop's access control and
replay protection are **declared but never enforced**, allowing the entire
distribution mechanism to be bypassed.
​
| ID | Severity | Title |
|----|----------|-------|
| H-1 | High | `Snowman.mintSnowman` has no access control — anyone can mint unlimited NFTs |
| H-2 | High | Airdrop never checks `s_hasClaimedSnowman` — claims can be repeated |
| M-1 | Medium | Malformed EIP-712 typehash breaks standard-wallet signatures |
| M-2 | Medium | Unbounded mint loop over a wei-denominated amount causes out-of-gas |
| L-1 | Low | Global `s_earnTimer` lets any user reset everyone's farming cooldown |
| L-2 | Low | Claim signature has no nonce or deadline — replayable |
​
> Note: the official contest validated **2 High, 1 Medium, 2 Low**. This report lists
> what this review independently surfaced; some items (e.g. L-2) may be judged as
> duplicates of, or informational relative to, the official set. Finding titles in the
> official report were not available for a 1:1 comparison.
​
---
​
## H-1 — `Snowman.mintSnowman` has no access control
​
**Location:** `src/Snowman.sol:36`
​
```solidity
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
...
}
}
```
​
The function is `external` with no caller restriction. It is intended to be called
only by `SnowmanAirdrop`, but nothing enforces this. The error `SM__NotAllowed` is
declared (`src/Snowman.sol:20`) yet never used.
​
**Impact:** Any address can mint an arbitrary number of `Snowman` NFTs to any
recipient, completely bypassing the Merkle proof, the signature check, and the
`Snow` staking requirement. The airdrop's entire eligibility mechanism is void.
​
**Proof of concept:** `test/AuditPoC.t.sol::test_CRITICAL_unrestrictedMint` — a
non-owner, non-airdrop address mints 5 NFTs to itself.
​
**Recommendation:** Restrict minting to the airdrop contract, e.g. store the airdrop
address at deploy and gate with it (reusing the existing `SM__NotAllowed`):
​
```solidity
address private immutable i_airdrop;
function mintSnowman(address receiver, uint256 amount) external {
if (msg.sender != i_airdrop) revert SM__NotAllowed();
...
}
```
​
---
​
## H-2 — Airdrop never checks `s_hasClaimedSnowman`; claims can be repeated
​
**Location:** `src/SnowmanAirdrop.sol:69-99` (flag set at `:94`, never read as a guard)
​
`claimSnowman` writes `s_hasClaimedSnowman[receiver] = true` but no code path ever
checks it. The only implicit protection is that a claim transfers the receiver's
entire `Snow` balance away (`:92`), leaving `balanceOf == 0`. However, `Snow` can be
re-acquired at will via `Snow.buySnow` / `Snow.earnSnow`. Once the receiver again
holds exactly the eligible `amount`, the recorded Merkle leaf matches again and the
**same `merkleProof` and signature** pass a second time (the signature has no nonce —
see L-2).
​
**Impact:** An eligible user can claim their Snowman NFTs repeatedly, minting far
more NFTs than intended per eligibility entry.
​
**Proof of concept:** `test/AuditPoC.t.sol::test_HIGH_repeatClaim` — the same receiver
claims twice with identical proof + signature and ends with `2 * amount` NFTs.
​
**Recommendation:** Enforce the flag at the start of `claimSnowman`:
​
```solidity
if (s_hasClaimedSnowman[receiver]) revert SA__AlreadyClaimed();
```
​
---
​
## M-1 — Malformed EIP-712 typehash breaks standard-wallet signatures
​
**Location:** `src/SnowmanAirdrop.sol:49`
​
```solidity
bytes32 private constant MESSAGE_TYPEHASH =
keccak256("SnowmanClaim(addres receiver, uint256 amount)");
```
​
The `encodeType` string is non-compliant: `addres` is a typo for `address`, and
EIP-712 requires no spaces after the field separator. The correct string is
`"SnowmanClaim(address receiver,uint256 amount)"`.
​
**Impact:** A signature produced by any standard EIP-712 signer (wallets,
`eth_signTypedData`, ethers/viem) is computed over the *correct* typehash and will
not match the contract's digest, so it fails verification. The claim-on-behalf
feature only works if the signer reproduces the exact malformed typehash — i.e. it
is broken for all standard tooling.
​
**Recommendation:** Fix the type string:
​
```solidity
keccak256("SnowmanClaim(address receiver,uint256 amount)");
```
​
---
​
## M-2 — Unbounded mint loop over a wei-denominated amount causes out-of-gas
​
**Location:** `src/Snowman.sol:37` + `src/SnowmanAirdrop.sol:98`
​
`mintSnowman` loops `amount` times, one `_safeMint` per iteration, where `amount` is
the receiver's `Snow` balance in wei. For any realistic balance (1 token = `1e18`),
the loop runs ~`1e18` iterations and always exceeds the block gas limit.
​
**Impact:** A legitimate claim with a normal 18-decimal balance reverts with
out-of-gas. The airdrop is unusable except for dust-sized integer balances. (Depending
on intended token economics this may be argued High, as the core function is
unusable.)
​
**Recommendation:** Decide the NFT count explicitly — e.g. mint a fixed number per
claim, or scale by `amount / 1e18`, rather than looping over raw wei.
​
---
​
## L-1 — Global `s_earnTimer` lets any user reset everyone's cooldown
​
**Location:** `src/Snow.sol:30, 87, 92-99`
​
`s_earnTimer` is a single contract-wide variable, not per-user. Any call to
`earnSnow` or `buySnow` sets it to `block.timestamp`, so one user's activity resets
the one-week farming cooldown for all users.
​
**Impact:** Cross-user griefing; the intended per-user weekly earn cadence is not
enforced per account.
​
**Recommendation:** Track the timer per address:
`mapping(address => uint256) private s_earnTimer;`
​
---
​
## L-2 — Claim signature has no nonce or deadline (replayable)
​
**Location:** `src/SnowmanAirdrop.sol:102-122`
​
The signed message binds only `(receiver, amount)`; there is no nonce or expiry.
Combined with H-2, a captured signature remains valid indefinitely and can be reused.
​
**Recommendation:** Include a per-receiver nonce (incremented on claim) and an
expiry timestamp in the signed struct.
​
---
​
## Proof-of-concept
​
All PoCs are in `test/AuditPoC.t.sol`. Run:
​
```bash
forge test --mc AuditPoC -vvv
```
​
Both `test_CRITICAL_unrestrictedMint` and `test_HIGH_repeatClaim` pass, demonstrating
the exploits against the unmodified contracts.
call it directly and mint arbitrarily many NFTs, bypassing the whole airdrop. The declared error SM__NotAllowed is never used.

Risk

Likelihood:high

Impact:hugh

Proof of Concept

function test_anyoneCanMint() public {
address attacker = makeAddr("attacker");
vm.prank(attacker);
snowman.mintSnowman(attacker, 5); // not owner, not airdrop
assertEq(snowman.balanceOf(attacker), 5);
}

Recommended Mitigation

+ address private immutable i_airdrop;
+ // set i_airdrop in the constructor to the SnowmanAirdrop address
function mintSnowman(address receiver, uint256 amount) external {
+ if (msg.sender != i_airdrop) revert SM__NotAllowed();
for (uint256 i = 0; i < amount; i++) {
Updates

Lead Judging Commences

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