DatingDapp

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

blockProfile` does not actually block — blocked and self-burned users can re-enter freely


Description

The contract provides blockProfile as an owner-only moderation tool intended to remove a user from the platform. The implementation burns the token and deletes all profile state:

function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress]; // resets to 0
delete _profiles[tokenId];
emit ProfileBurned(blockAddress, tokenId);
}

The only guard that prevents duplicate minting in mintProfile is:

require(profileToToken[msg.sender] == 0, "Profile already exists");

Because blockProfile resets profileToToken[blockAddress] to 0 via delete, this guard passes immediately for the blocked address. The blocked user can call mintProfile in the very next transaction with the same wallet and receive a fresh token — the block has no lasting effect.

The same path exists for self-burns via burnProfile, which also deletes profileToToken[msg.sender]. A user who burns their own profile (whether to evade a pending moderation action or for any other reason) can re-mint without any admin approval.

Three compounding problems make this severe in practice:

  1. No blocklist exists. There is no separate mapping recording that an address has been blocked. Once blockProfile runs, all record of the moderation action is erased on-chain. The admin has no way to tell a previously-blocked address from a brand-new user.

  2. No unblock mechanism. Because there is no blocklist, the admin cannot selectively re-admit a user who was blocked by mistake. Every block is permanent and irreversible for the admin, yet completely ineffective at keeping the user out.

  3. Same address, no friction. The blocked or self-burned user does not need a new wallet. They re-enter with the exact same address, bypassing any off-chain reputation or history tied to that address.

In a real-world dating platform, burning or blocking an account is expected to carry lasting consequences — removal from matching pools, loss of accumulated reputation, and prevention of immediate re-entry. None of these hold here.

Impact

The owner's only moderation capability is completely non-functional. A user who is blocked for abusive behaviour can rejoin in one transaction with the same address, same reputation history visible off-chain, and no admin approval required. Self-burned users similarly face no cooldown or re-admission barrier. Platform safety and moderation are broken.

PoC Result

PoC 1 — Blocked user re-mints: testPoC_BlockedUserCanRemint in test/testSoulboundProfileNFT.t.sol

// PoC: blockProfile deletes the profile mapping but does not prevent re-minting.
// After the owner blocks a user, profileToToken[user] == 0, which is exactly the
// condition mintProfile requires to allow a new mint. The blocked user can
// immediately re-join the platform with a fresh profile.
function testPoC_BlockedUserCanRemint() public {
// Step 1: user mints a profile
vm.prank(user);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
uint256 firstTokenId = soulboundNFT.profileToToken(user);
assertEq(firstTokenId, 1, "user has token 1 before block");
// Step 2: owner blocks the user — intended to remove them from the platform
vm.prank(owner);
soulboundNFT.blockProfile(user);
assertEq(soulboundNFT.profileToToken(user), 0, "mapping cleared after block");
// Step 3: blocked user immediately re-mints — block is completely ineffective
vm.prank(user);
soulboundNFT.mintProfile("Alice_v2", 25, "ipfs://newImage");
uint256 secondTokenId = soulboundNFT.profileToToken(user);
// A new token was issued to the blocked address — block had no lasting effect
assertGt(secondTokenId, firstTokenId, "blocked user received a new token after re-mint");
assertEq(soulboundNFT.ownerOf(secondTokenId), user, "blocked user owns new token");
}
[PASS] testPoC_BlockedUserCanRemint() (gas: 268,996)
Flow:
user mints → profileToToken[user] = 1
owner blockProfile → profileToToken[user] = 0 (deleted)
user mints again → profileToToken[user] = 2 (no revert)
ownerOf(2) = user (block had no effect)

PoC 2 — Self-burn re-mints: testPoC_SelfBurnAndRemint in test/testSoulboundProfileNFT.t.sol

// PoC: a user who self-burns their profile via burnProfile can immediately
// re-mint with the same address. burnProfile deletes profileToToken[msg.sender],
// which is the only guard mintProfile checks. The user re-enters the platform
// with no admin permission and no restriction — exactly as if they never left.
function testPoC_SelfBurnAndRemint() public {
// Step 1: user mints and then burns their own profile
vm.startPrank(user);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
uint256 firstTokenId = soulboundNFT.profileToToken(user);
assertEq(firstTokenId, 1, "user has token 1 before burn");
soulboundNFT.burnProfile();
assertEq(soulboundNFT.profileToToken(user), 0, "mapping cleared after self-burn");
// Step 2: same address re-mints immediately — no admin approval needed
soulboundNFT.mintProfile("Alice_new", 26, "ipfs://newImage");
vm.stopPrank();
uint256 secondTokenId = soulboundNFT.profileToToken(user);
assertGt(secondTokenId, firstTokenId, "user received a new token after self-burn");
assertEq(soulboundNFT.ownerOf(secondTokenId), user, "user owns new token");
}
[PASS] testPoC_SelfBurnAndRemint() (gas: 264,696)
Flow:
user mints → profileToToken[user] = 1
user burnProfile → profileToToken[user] = 0 (deleted)
user mints again → profileToToken[user] = 2 (no revert)
ownerOf(2) = user (user re-entered freely)

Run with: forge test --match-test "testPoC_BlockedUserCanRemint|testPoC_SelfBurnAndRemint" -vvv

Recommended Mitigation

Introduce a persistent blocklist that is checked independently of the token mapping, and remove the block record only through an explicit admin unblock:

mapping(address => bool) public isBlocked;
function blockProfile(address blockAddress) external onlyOwner {
isBlocked[blockAddress] = true; // persists after burn
uint256 tokenId = profileToToken[blockAddress];
require(tokenId != 0, "No profile found");
_burn(tokenId);
delete profileToToken[blockAddress];
delete _profiles[tokenId];
emit ProfileBurned(blockAddress, tokenId);
}
function unblockProfile(address blockAddress) external onlyOwner {
isBlocked[blockAddress] = false;
}
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
require(!isBlocked[msg.sender], "Address is blocked");
require(profileToToken[msg.sender] == 0, "Profile already exists");
// ...
}

For self-burned users, decide at the protocol design level whether re-minting should be freely allowed or gated behind an admin allowlist. The current behaviour (free re-entry) may or may not be intentional, but it should be an explicit design choice rather than an accidental consequence of the delete pattern.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] `SoulboundProfileNFT::blockProfile` make it possible to recreate the profile

## Description The `SoulboundProfileNFT::blockProfile` function uses `delete profileToToken[blockAddress]`, which resets `profileToToken[blockAddress]` to `0`. Since the mintProfile function checks for an existing profile by verifying that `profileToToken[msg.sender] == 0`, a blocked account can be recreated by simply minting a new profile. This behavior bypasses the intended permanent block functionality. ## Vulnerability Details By deleting the mapping entry for a blocked account, the contract inadvertently allows a new mintProfile call to pass the check `require(profileToToken[msg.sender] == 0, "Profile already exists")`. Essentially, once an account is blocked, its associated mapping entry is cleared, so the condition to identify an account with an existing profile is no longer met. This loophole enables a blocked account to recreate its profile, undermining the purpose of blocking. ## Impact A blocked account, which should be permanently barred from engaging with the platform, can circumvent this restriction by re-minting its profile. The integrity of the platform is compromised, as blocked users could regain access and potentially perform further malicious actions. ## POC ```solidity function testRecereationOfBlockedAccount() public { // Alice mints a profile successfully vm.prank(user); soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice"); // Owner blocks Alice's account, which deletes Alice profile mapping vm.prank(owner); soulboundNFT.blockProfile(user); // The blocked user (Alice) attempts to mint a new profile. // Due to the reset mapping value (0), the require check is bypassed. vm.prank(user); soulboundNFT.mintProfile("Alice", 18, "ipfs://profileImageAlice"); } ``` ## Recommendations - When blocking an account, implement a mechanism to permanently mark that address as blocked rather than simply deleting an entry. For example, maintain a separate mapping (e.g., isBlocked) to record blocked accounts, and update mintProfile to check if an account is permanently barred from minting: Example modification: ```diff + mapping(address => bool) public isBlocked; ... function mintProfile(string memory name, uint8 age, string memory profileImage) external { + require(!isBlocked[msg.sender], "Account is permanently blocked"); 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); } ... function blockProfile(address blockAddress) external onlyOwner { uint256 tokenId = profileToToken[blockAddress]; require(tokenId != 0, "No profile found"); _burn(tokenId); delete profileToToken[blockAddress]; delete _profiles[tokenId]; + isBlocked[blockAddress] = true; emit ProfileBurned(blockAddress, tokenId); } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!