mintProfile() lets a contract mint multiple profile NFTs for the same addressSeverity: Medium
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])
The vulnerable flow is:
The issue is the ordering:
The function checks profileToToken[msg.sender] == 0.
It increments _nextTokenId.
It calls _safeMint(msg.sender, tokenId).
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])
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.
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])
SoulboundProfileNFT is deployed
mintProfile() uses _safeMint
profileToToken is only updated after _safeMint returns
The caller is a contract
The caller implements onERC721Received
The callback reenters mintProfile()
Attacker deploys a contract implementing onERC721Received.
Attacker contract calls mintProfile(...).
mintProfile() passes the profileToToken[msg.sender] == 0 check.
_safeMint() mints token #1 and calls onERC721Received on the attacker contract.
Inside onERC721Received, the attacker reenters mintProfile(...).
profileToToken[msg.sender] is still 0, so the second call also passes.
A second token is minted for the same address.
After unwinding, profileToToken[msg.sender] points only to the latest token, while the same address owns multiple NFTs.
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.
Use one of these fixes:
Preferred fix: update state before external interaction
Set the uniqueness marker before calling _safeMint():
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])
## 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); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.