Each address may hold one profile: mintProfile requires profileToToken[msg.sender] == 0 (L31). Each profile is soulbound. The holder can remove it with burnProfile and the owner with blockProfile.
mintProfile calls _safeMint (L34) before it writes _profiles and profileToToken (L37-L38), and it has no reentrancy guard. In the pinned OpenZeppelin 5.0.2, _safeMint runs _mint and then _checkOnERC721Received (ERC721.sol L312-L315). That function calls onERC721Received on a contract recipient (L465-L467). Inside that callback profileToToken[msg.sender] still reads 0, so the recipient can call mintProfile again and receive another token. When the outer call resumes, it overwrites profileToToken with the first token id. burnProfile (L45) and blockProfile (L58) find a token only through profileToToken, and the transfer overrides (L69-L77) always revert. Every token minted inside the callback is therefore orphaned: it can never be burned, blocked or moved.
Likelihood:
Anyone can trigger it with a contract whose onERC721Received calls mintProfile again. It needs no role, no funds and no particular ordering.
It needs a purpose-built receiver contract, and it gives the attacker no extra like or match power, because LikeRegistry gates only on profileToToken, which still points to one token. It is therefore a deliberate act, not something honest users run into.
Impact:
The one-profile-per-address invariant breaks. In the PoC, one call leaves balanceOf(attacker) == 2 while profileToToken points to token 1, and emits two ProfileMinted events for the same address. With nine re-entries, one transaction mints 10 profiles.
The orphaned profiles and their metadata are permanent. burnProfile and blockProfile burn only the mapped token, then revert No profile found. tokenURI(2) returns the same metadata after a block as before it. Repeating attack-then-block leaves one more undeletable profile each round. No ETH is at risk.
The PoC deploys SoulboundProfileNFT and two receiver contracts:
HonestReceiver accepts the token and never re-enters.
ReentrantMinter's onERC721Received calls mintProfile again a configurable number of times.
The baseline shows that a second mint from an EOA, or from the honest contract, reverts Profile already exists. The attack tests show the following:
One re-entry: the attacker holds tokens 1 and 2, profileToToken points to 1, and two ProfileMinted events are emitted for the same address.
Nine re-entries: the attacker holds 10 profiles from one transaction.
Orphaned token: after the holder burns, or the owner blocks, the mapped token:
token 2 is still owned by the attacker;
a second burn or block reverts No profile found;
transferFrom and safeTransferFrom revert;
tokenURI(2) returns the same metadata as before.
Repeat: three rounds of attack-then-block leave three undeletable profiles (tokens 2, 4 and 6).
Run from the repository root, with the file saved as test/PoC_DD-06.t.sol:
Result: Suite result: ok. 6 passed; 0 failed; 0 skipped. The logs show balanceOf(attacker): 2, profileToToken(attacker): 1, balanceOf(attacker) after attack(9): 10 and orphans after 3 attack+block rounds: 3.
Write the state before the external call (checks-effects-interactions). A re-entrant call then fails the L31 check and the whole mint reverts. Two alternatives also work: add OpenZeppelin's ReentrancyGuard to mintProfile, or use _mint instead of _safeMint.
## 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.