Snowman Merkle Airdrop

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

`_safeMint` ERC721 callback enables cross-contract reentrancy into Snowman during the mint loop

Root + Impact

Description

  • _safeMint from OZ ERC721 invokes onERC721Received on contract receivers between successive mints. The nonReentrant modifier on SnowmanAirdrop.claimSnowman only protects the airdrop's own state, not the Snowman contract or any external integration.

  • A malicious receiver gets execution between mint iterations and can re-enter Snowman.mintSnowman directly (because C-01 leaves it open) or read mid-state from any integrator.

// src/Snowman.sol
function mintSnowman(address receiver, uint256 amount) external {
for (uint256 i = 0; i < amount; i++) {
@> _safeMint(receiver, s_TokenCounter); // @> callback fires here
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}

Risk

Likelihood:

  • Reason 1: The receiver in claimSnowman can be any contract that implements onERC721Received — no allowlist.

  • Reason 2: With C-01 unfixed, the same callback re-enters mintSnowman to inflate the receiver's balance further within the same transaction.

Impact:

  • Impact 1: Amplifies C-01: a malicious receiver mints additional Snowman NFTs in the callback.

  • Impact 2: Read-only reentrancy exposure for any future integration that reads Snowman.getTokenCounter or Snowman.balanceOf mid-callback.

Proof of Concept

The PoC is a single contract (EvilReceiver) deployed as the airdrop's receiver. When _safeMint calls onERC721Received during the very first iteration of the mint loop, control hands back to EvilReceiver while mintSnowman is still executing. From inside the callback, the receiver re-enters mintSnowman(address(this), 100) directly — possible because C-01 leaves the function unguarded. Each nested call also fires another callback, allowing arbitrary recursion depth bounded only by gas. The vulnerability is the loss of atomicity: by the time the outer mintSnowman finishes its amount iterations, the receiver holds far more NFTs than the airdrop authorized.

contract EvilReceiver is IERC721Receiver {
Snowman target;
constructor(Snowman _t) { target = _t; }
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
target.mintSnowman(address(this), 100); // re-enter while outer loop holds
return this.onERC721Received.selector;
}
}

Recommended Mitigation

There are two complementary mitigations. The first is structural: once C-01 is fixed and mintSnowman is gated to onlyAirdrop, the callback-driven re-entry path closes because EvilReceiver would need to be the airdrop contract. The second hardens the airdrop itself by enforcing strict Checks-Effects-Interactions order: every state change (including the s_hasClaimedSnowman flag from C-03) must happen before any external call that could yield control. This makes the read-only reentrancy surface harmless to any external integrator that might later read Snowman mid-mint.

function claimSnowman(...) external nonReentrant {
...
+ s_hasClaimedSnowman[receiver] = true; // effects before interactions
i_snow.safeTransferFrom(receiver, address(this), amount);
emit SnowmanClaimedSuccessfully(receiver, amount);
i_snowman.mintSnowman(receiver, amount);
}

If C-01 cannot be fixed for some external reason, an alternative is to replace _safeMint with _mint inside Snowman — eliminating the callback path at the cost of breaking ERC721 receiver-safety guarantees for contract holders.

Updates

Lead Judging Commences

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