Snowman Merkle Airdrop

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

`Snowman.mintSnowman` violates the Checks-Effects-Interactions(CEI) pattern by incrementing `s_TokenCounter` after an external callback exposing stale token state

Root + Impact

Description


mintSnowman calls _safeMint before incrementing s_TokenCounter. OpenZeppelin's _safeMint invokes onERC721Received on contract receivers before returning, meaning external code executes while s_TokenCounter still holds the pre-increment value. Any receiver contract that reads getTokenCounter() during that callback observes a stale count.

This violates the Checks-Effects-Interactions pattern, which requires all state changes to be committed before any external interaction occurs.

// src/Snowman.sol
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
@> _safeMint(receiver, s_TokenCounter);
// ↑ External call fires onERC721Received BEFORE s_TokenCounter is incremented
// During callback: getTokenCounter() returns stale N, not N+1
@> emit SnowmanMinted(receiver, s_TokenCounter);
// ↑ Event emitted AFTER external call — CEI violated
@> s_TokenCounter++;
// ↑ State update happens LAST, violating CEI pattern
}
}

Risk

Likelihood:

  • The stale-state window is triggered whenever mintSnowman() mints to a contract implementing IERC721Receiver, as _safeMint() automatically invokes onERC721Received(). However, OpenZeppelin's ERC721 implementation prevents duplicate token minting, and no practical attack path currently exists that allows an attacker to gain additional NFTs, steal funds, or corrupt protocol state through this callback.


Impact:

Although OpenZeppelin's ERC721 implementation prevents duplicate token IDs and no direct theft or double-mint scenario exists, external integrations may observe inconsistent state during the callback window.
Specifically:

  • A receiver contract can observe a stale s_TokenCounter value during onERC721Received().

  • Future protocol upgrades may accidentally introduce exploitable reentrancy if additional state-dependent logic is added around minting.

  • The implementation does not follow established Solidity security best practices, increasing maintenance and upgrade risk.

Proof of Concept

The test deploys Snowman alongside a custom ReceiverThatReadsCounter contract that implements IERC721Receiver. When mintSnowman is called with this contract as the receiver, OpenZeppelin's _safeMint invokes onERC721Received on the receiver before the s_TokenCounter++ line executes. Inside that callback, the receiver reads getTokenCounter() and stores the value it observes. After the full mint completes, the test asserts that the counter value seen during the callback equals 0 — the same as the token ID just minted — confirming that s_TokenCounter was not yet incremented when external code was running. The post-mint assertion shows the counter correctly reads 1 after the function returns, proving a stale-state window exists during the entire callback execution.

To run: forge test --match-test test_StaleCounterObservedDuringCallback -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {IERC721Receiver} from
"@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {Snowman} from "../src/Snowman.sol";
contract ReceiverThatReadsCounter is IERC721Receiver {
Snowman public snowman;
uint256 public counterSeenDuringCallback;
uint256 public tokenReceived;
constructor(address _snowman) {
snowman = Snowman(_snowman);
}
function onERC721Received(
address,
address,
uint256 tokenId,
bytes calldata
) external returns (bytes4) {
tokenReceived = tokenId;
// Counter has not yet been incremented
counterSeenDuringCallback = snowman.getTokenCounter();
return IERC721Receiver.onERC721Received.selector;
}
}
contract CEIPoC is Test {
Snowman snowman;
ReceiverThatReadsCounter receiver;
function setUp() public {
snowman = new Snowman("dummyURI");
receiver = new ReceiverThatReadsCounter(address(snowman));
}
function test_StaleCounterObservedDuringCallback() public {
snowman.mintSnowman(address(receiver), 1);
assertEq(receiver.tokenReceived(), 0);
// Callback observed stale value
assertEq(receiver.counterSeenDuringCallback(), 0);
// Final state after mint
assertEq(snowman.getTokenCounter(), 1);
}
}

Recommended Mitigation

The minimal CEI fix is to increment s_TokenCounter inside the loop but before _safeMint fires. Cache the pre-increment value into a local tokenId variable, increment s_TokenCounter immediately, then call _safeMint using the cached value.

This ensures the storage update is committed before any external callback executes — getTokenCounter() called from inside onERC721Received will now return the correct post-mint value.

function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
+ uint256 tokenId = s_TokenCounter;
+ s_TokenCounter++; // EFFECT before INTERACTION
- _safeMint(receiver, s_TokenCounter);
+ _safeMint(receiver, tokenId); // external callback fires after state update
- emit SnowmanMinted(receiver, s_TokenCounter);
+ emit SnowmanMinted(receiver, tokenId);
- s_TokenCounter++;
}
}
Updates

Lead Judging Commences

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