Vulnerability Details
The LikeRegistry::likeUser
function allows a user to like another user by providing their wallet address as a parameter. However, the function does not check whether the liked user has already matched with someone else. If they have, their funds are already pooled in a multisig wallet with another user. As a result, when a new user likes them, given, if they had liked the new user previously, a new multisig wallet is created with only the new liker’s funds, as the liked user’s balance is already zero.
Example Scenario
Bob, Alice, and Cathy are users of the platform.
Bob has already liked Alice and Cathy.
Cathy likes Bob back, creating a multisig wallet with their pooled funds.
Alice then likes Bob back, creating another multisig wallet. However, Bob’s balance is now zero, meaning only Alice’s funds are pooled.
Impact
The pool becomes single-sided, meaning only one person is funding the date.
An attacker can exploit this by creating two fake profiles and matching them (Bob and Cathy), then liking real users, and getting dates without paying any money.
Proof of Concept
To test this vulnerability, the following test case demonstrates how two multisig wallets are created, but one of them is single-sided due to missing funds from one of the users.
function likeUser(address liked) external payable returns (MultiSigWallet multiSigWallet) {
require(msg.value >= 1 ether, "Must send at least 1 ETH");
require(!likes[msg.sender][liked], "Already liked");
require(msg.sender != liked, "Cannot like yourself");
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
require(profileNFT.profileToToken(liked) != 0, "Liked user must have a profile NFT");
likes[msg.sender][liked] = true;
emit Liked(msg.sender, liked);
userBalances[msg.sender] += msg.value; <@
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
returns matchRewards(liked, msg.sender);
}
}
function matchRewards(address from, address to) internal returns (MultiSigWallet multiSigWallet){
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
userBalances[from] = 0;
userBalances[to] = 0;
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE ) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
multiSigWallet = new MultiSigWallet(from, to);
(bool success, ) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}
function test_double_match() public {
vm.prank(user);
soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");
vm.prank(user2);
soulboundNFT.mintProfile("Bob", 26, "ipfs://profileImage");
vm.prank(user3);
soulboundNFT.mintProfile("Cathy", 28, "ipfs://profileImage");
deal(user, 3 ether);
deal(user2, 3 ether);
deal(user3, 3 ether);
vm.startPrank(user2);
likeRegistry.likeUser{value: 1 ether}(user);
likeRegistry.likeUser{value: 1 ether}(user3);
vm.stopPrank();
vm.prank(user);
MultiSigWallet multisig1 = likeRegistry.likeUser{value: 1 ether}(user2);
vm.prank(user3);
MultiSigWallet multisig2 = likeRegistry.likeUser{value: 1 ether}(user2);
vm.assertEq(address(multisig1).balance, 2.7 ether);
vm.assertEq(address(multisig2).balance, 0.9 ether);
}
Execution Trace
│ ├─ [627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000456) [staticcall]
│ │ └─ ← [Return] 2
│ ├─ emit Liked(liker: user3: [0xc0A55e2205B289a967823662B841Bd67Aa362Aec], liked: 0x0000000000000000000000000000000000000456)
│ ├─ emit Matched(user1: user3: [0xc0A55e2205B289a967823662B841Bd67Aa362Aec], user2: 0x0000000000000000000000000000000000000456)
│ ├─ [483834] → new MultiSigWallet@0xCB6f5076b5bbae81D7643BfBf57897E8E3FB1db9
│ │ └─ ← [Return] 2193 bytes of code
│ ├─ [55] MultiSigWallet::receive{value: 900000000000000000}()
│ │ └─ ← [Stop]
│ └─ ← [Return] MultiSigWallet: [0xCB6f5076b5bbae81D7643BfBf57897E8E3FB1db9]
│ ├─ [0] VM::assertEq(2700000000000000000 [2.7e18], 2700000000000000000 [2.7e18]) [staticcall]
│ │ └─ ← [Return]
│ ├─ [0] VM::assertEq(900000000000000000 [9e17], 900000000000000000 [9e17]) [staticcall]
│ │ └─ ← [Return]
│ └─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.26ms (404.05µs CPU time)
Recommendations
1. Ensure the Liked User Has Funds
Modify likeUser
to check if the liked user has sufficient funds before proceeding:
function likeUser(address liked) external payable {
require(userBalances[liked] > 0, "Liked user currently has no money in the contract");
}
However, this could lead to scenarios where users are forced to like new profiles to deposit ETH before being able to match. To resolve this, modify the receive
function to allow direct ETH deposits:
receive() external payable {
require(profileNFT.profileToToken(msg.sender) != 0, "Must have a profile NFT");
require(msg.value >= 1 ether, "Must send at least 1 ETH");
userBalances[msg.sender] += msg.value;
}
2. Restrict Users to One Match at a Time
Update the matches
mapping to prevent multiple matches:
mapping(address => address) public match;
Modify likeUser
to enforce this restriction:
function likeUser(address liked) external payable {
require(match[msg.sender] == address(0), "User currently has a match");
require(match[liked] == address(0), "Liked user currently has a match");
}
3. Add an unmatchUser
Function
To allow users to reset their matches if a date does not go well, implement an unmatchUser
function:
function unmatchUser() external {
require(match[msg.sender] != address(0), "No active match");
address previousMatch = match[msg.sender];
match[msg.sender] = address(0);
match[previousMatch] = address(0);
emit Unmatched(msg.sender, previousMatch);
}
This ensures users can move on if they wish to find a new match.