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.
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.
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.
Run from the repository root, with the file saved as test/PoC_DD-04.t.sol:
Result: Suite result: ok. 5 passed; 0 failed; 0 skipped.
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.
## 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); } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.