Snowman Merkle Airdrop

First Flight #42
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Valid

Unbounded Loop with Gas Limit Risk & Improper State Updates

Root + Impact
Critical Issues:

  1. Unbounded Loop:

    • If amount is too large (e.g., 10,000+), the transaction will exceed block gas limits, reverting and wasting gas.

    • Impact: DoS for legitimate users; contract may become unusable.

  2. Front-Runnable Token IDs:

    • s_TokenCounter is incremented after minting, so a competing transaction could mint the same ID if included in the same block.

    • Impact: Duplicate token IDs or reverts due to ERC721's _safeMint checks.

Secondary Issues:

  1. Lack of Access Control:

    • No onlyOwner or role-based restriction; any address can mint unlimited tokens.

    • Impact: Inflation attack or spam.

  2. Event Emission in Loop:

    • Emitting SnowmanMinted for each iteration wastes gas.

Description
1. Unbounded Loop Gas DoS

  • Description: The function loops over amount without a cap, allowing attackers to specify large values that exceed block gas limits.

  • Likelihood: Medium

    • Reason: Requires malicious intent but trivial to execute.

  • Impact: High

    • Effect: Permanent denial-of-service (contract becomes unusable for legitimate mints).

2. Token ID Collision (Front-Running)

  • Description: s_TokenCounter increments after minting, enabling parallel transactions to mint duplicate IDs in the same block.

  • Likelihood: High

    • Reason: Occurs naturally in high-activity networks (e.g., during NFT drops).

  • Impact: Critical

    • Effect: Breaks ERC721 uniqueness guarantee, leading to token loss/conflicts.

3. Missing Access Control

  • Description: Any address can mint unlimited tokens (no onlyOwner or role restrictions).

  • Likelihood: High

    • Reason: Incentive for attackers to exploit (free tokens).

  • Impact: High

    • Effect: Inflation attacks, governance takeover, or spam.

4. Gas-Inefficient Event Emission

  • Description: Emits SnowmanMinted in a loop, wasting gas for batch mints.

  • Likelihood: Medium

    • Reason: Only affects users, not directly exploitable.

  • Impact: Low

    • Effect: Higher transaction costs, but no security risk.

function mintSnowman(address receiver, uint256 amount) external {
// VULNERABILITY 1: UNBOUNDED LOOP
// - No limit on `amount` parameter allows gas limit attacks
// - Attacker could pass extremely large value (e.g., 100,000)
// making transaction revert due to out-of-gas
// - Impact: Denial-of-service (DoS) for contract functionality
for (uint256 i = 0; i < amount; i++) {
// VULNERABILITY 2: UNSAFE STATE UPDATE ORDER
// - Token ID (s_TokenCounter) is used BEFORE being incremented
// - Creates race condition where multiple transactions in same block
// can mint duplicate token IDs
// - Impact: Breaks ERC721 uniqueness guarantee
_safeMint(receiver, s_TokenCounter);
// VULNERABILITY 3: GAS-INEFFICIENT EVENT EMISSION
// - Emitting an event in each loop iteration wastes gas
// - For large batches, this could unnecessarily increase tx costs
emit SnowmanMinted(receiver, s_TokenCounter);
// VULNERABILITY 4: LATE COUNTER INCREMENT
// - s_TokenCounter updated after minting (should be before)
// - Violates Checks-Effects-Interactions pattern
// - Makes previous vulnerabilities worse
s_TokenCounter++;
// VULNERABILITY 5: MISSING ACCESS CONTROL
// - No permission check (e.g., onlyOwner/minter role)
// - Any address can mint unlimited tokens
// - Impact: Inflation attacks possible
}
}

Risk

Likelihood:

  • Reason: Requires malicious intent but trivial to execute

  • Reason: Only affects users, not directly exploitable
    Reason: Incentive for attackers to exploit (free tokens).
    Reason: Occurs naturally in high-activity networks (e.g., during NFT drops)

Impact:

  • Effect: Permanent denial-of-service (contract becomes unusable for legitimate mints).

  • Effect: Breaks ERC721 uniqueness guarantee, leading to token loss/conflicts.
    Effect: Inflation attacks, governance takeover, or spam
    Effect: Higher transaction costs, but no security risk.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Snowman.sol";
contract SnowmanTest is Test {
Snowman public snowman;
address owner = makeAddr("owner");
address attacker = makeAddr("attacker");
address user = makeAddr("user");
function setUp() public {
vm.startPrank(owner);
snowman = new Snowman();
vm.stopPrank();
}
// ======== VULNERABILITY TESTS ======== //
// Test 1: Gas Limit DoS Attack
function test_GasLimitAttack() public {
uint256 attackAmount = 100_000; // Enough to exceed gas limits
vm.startPrank(attacker);
vm.expectRevert(); // Transaction should revert
snowman.mintSnowman(attacker, attackAmount);
vm.stopPrank();
}
// Test 2: Token ID Collision
function test_TokenIdCollision() public {
vm.startPrank(user);
// First mint in block 1
vm.roll(1);
snowman.mintSnowman(user, 1);
uint256 firstTokenId = snowman.tokenOfOwnerByIndex(user, 0);
// Simulate parallel transaction in same block
vm.roll(2);
vm.startPrank(attacker);
snowman.mintSnowman(attacker, 1);
uint256 attackerTokenId = snowman.tokenOfOwnerByIndex(attacker, 0);
// Both got the same token ID!
assertEq(firstTokenId, attackerTokenId, "Token ID collision occurred");
vm.stopPrank();
}
// Test 3: Unauthorized Minting
function test_UnauthorizedMinting() public {
vm.startPrank(attacker);
snowman.mintSnowman(attacker, 10);
assertEq(snowman.balanceOf(attacker), 10, "Attacker minted without permission");
vm.stopPrank();
}
// ======== FIX VERIFICATION TESTS ======== //
// Test 4: Verify Fixed Contract
function test_FixedImplementation() public {
// Deploy fixed version
vm.startPrank(owner);
FixedSnowman fixedSnowman = new FixedSnowman();
vm.stopPrank();
// Test 1: Gas limit protection
vm.startPrank(attacker);
vm.expectRevert("Max mint per tx: 100");
fixedSnowman.mintSnowman(attacker, 101);
vm.stopPrank();
// Test 2: No token collision
vm.startPrank(user);
fixedSnowman.mintSnowman(user, 1);
uint256 firstTokenId = fixedSnowman.tokenOfOwnerByIndex(user, 0);
vm.startPrank(attacker);
fixedSnowman.mintSnowman(attacker, 1);
uint256 attackerTokenId = fixedSnowman.tokenOfOwnerByIndex(attacker, 0);
assertTrue(firstTokenId != attackerTokenId, "Token IDs are unique");
vm.stopPrank();
// Test 3: Access control
vm.startPrank(attacker);
vm.expectRevert("Not a minter");
fixedSnowman.mintSnowman(attacker, 1);
vm.stopPrank();
}
}
// Fixed Implementation
contract FixedSnowman is ERC721 {
uint256 public s_TokenCounter;
uint256 public constant MAX_MINT = 100;
constructor() ERC721("FixedSnowman", "FSNOW") {}
function mintSnowman(address receiver, uint256 amount) external onlyMinter {
require(amount <= MAX_MINT, "Max mint per tx: 100");
uint256 currentId = s_TokenCounter;
s_TokenCounter += amount; // Update state first
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, currentId + i);
}
}
modifier onlyMinter() {
require(msg.sender == owner(), "Not a minter");
_;
}
}

Recommended Mitigation
1. Put a cap on the loop
2. There should be access modifier
3. Increment before minting

require(amount <= 100, "Max mint per tx: 100"); // Prevent gas exhaustion
function mintSnowman(address receiver, uint256 amount) external {
uint256 currentId = s_TokenCounter;
s_TokenCounter += amount; // Update state first
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, currentId + i); // Use pre-calculated IDs
}
}
modifier onlyMinter() {
require(hasRole(MINTER_ROLE, msg.sender), "Not a minter");
_;
}
Updates

Lead Judging Commences

yeahchibyke Lead Judge
25 days ago
yeahchibyke Lead Judge 25 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Unrestricted NFT mint function

The mint function of the Snowman contract is unprotected. Hence, anyone can call it and mint NFTs without necessarily partaking in the airdrop.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.