DatingDapp

First Flight #33
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: high
Invalid

In the LikeRegistry, the contract pools all ETH from both users’ balances including likes for other users, not just the ETH sent between the matched pair.

Summary

The LikeRegistry contract is designed to pool ETH from users when a mutual match occurs, deploying a multisig wallet with the pooled funds. However, the current design aggregates all ETH sent by a user into a single global balance via the userBalances mapping rather than tracking funds on a per-like basis. This means that if a user sends ETH for multiple likes but only one of those likes is reciprocated, all of their ETH including funds intended for other recipients will be pooled into the match. The exploit is only fully realized once the existing userBalance vulnerability is patched.

Vulnerability Details

The vulnerability stems from how the contract aggregates and later clears user funds in the matchRewards function.

function matchRewards(address from, address to) internal {
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;
// Deploy a MultiSig contract for the matched users
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
// Send ETH to the deployed multisig wallet
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

Here, the contract retrieves the total ETH from both users’ balances (userBalances[from] and userBalances[to]) and pools it together, regardless of which specific like contributed what portion. Suppose the following scenario:

  • Alice sends 1 ETH to like Bob, so userBalances[Alice] becomes 1 ETH.

  • Alice also sends 1 ETH to like Charlie, making userBalances[Alice] equal to 2 ETH.

  • Bob sends 1 ETH to like Alice, so userBalances[Bob] is 1 ETH.

If Bob and Alice then mutually match, the contract calculates:

  • totalRewards = userBalances[Alice] + userBalances[Bob] = 2 ETH + 1 ETH = 3 ETH

However, the intended behavior is for only the funds directly associated with the mutual like (1 ETH from Alice for Bob and 1 ETH from Bob for Alice) to be pooled—i.e., a total of 2 ETH. In this case, the extra 1 ETH that Alice sent for her like of Charlie is mistakenly included in the match with Bob, leading to misallocation of funds.

proof of concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
contract LikeRegistryCrossLikeExploitTest is Test {
LikeRegistry likeRegistry;
SoulboundProfileNFT profileNFT;
address user1 = address(0x400); // Fresh Alice
address user2 = address(0x500); // Fresh Bob
address user3 = address(0x600); // Fresh Charlie
function setUp() public {
profileNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(profileNFT));
// Fund users with ETH
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
vm.deal(user3, 10 ether);
// Mint profiles
vm.prank(user1);
profileNFT.mintProfile("Alice", 25, "ipfs://profileAlice");
vm.prank(user2);
profileNFT.mintProfile("Bob", 27, "ipfs://profileBob");
vm.prank(user3);
profileNFT.mintProfile("Charlie", 28, "ipfs://profileCharlie");
}
function testCrossLikePoolingExploit() public {
// Alice likes Bob (1 ETH)
vm.prank(user1);
likeRegistry.likeUser{value: 1 ether}(user2);
// Alice likes Charlie (1 ETH)
vm.prank(user1);
likeRegistry.likeUser{value: 1 ether}(user3);
// Bob likes Alice back (1 ETH), triggering matchRewards
vm.prank(user2);
likeRegistry.likeUser{value: 1 ether}(user1);
// Expected behavior: Only 2 ETH (Alice → Bob, Bob → Alice) should be pooled
uint256 contractBalance = address(likeRegistry).balance;
assertEq(contractBalance, 3 ether, "Contract should incorrectly pool 3 ETH instead of 2 ETH");
assertEq(likeRegistry.userBalances(user1), 0, "Alice's balance should be reset to 0");
assertEq(likeRegistry.userBalances(user2), 0, "Bob's balance should be reset to 0");
// Check if Charlie's ETH was improperly included
assertEq(likeRegistry.userBalances(user3), 0, "Charlie's ETH should NOT be drained into Alice-Bob match");
// Failure condition: If Charlie’s ETH is drained, the exploit exists
assertFalse(contractBalance == 2 ether, "Exploit confirmed: Unrelated ETH is pooled");
}
}

Below is the log from the poc

Ran 1 test for test/LikeRegistryCrossLikeExploitTest.t.sol:LikeRegistryCrossLikeExploitTest
[PASS] testCrossLikePoolingExploit() (gas: 753887)
Traces:
[753887] LikeRegistryCrossLikeExploitTest::testCrossLikePoolingExploit()
├─ [0] VM::prank(0x0000000000000000000000000000000000000400)
│ └─ ← [Return]
├─ [37514] LikeRegistry::likeUser{value: 1000000000000000000}(0x0000000000000000000000000000000000000500)
│ ├─ [2627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000400) [staticcall]
│ │ └─ ← [Return] 1
│ ├─ [2627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000500) [staticcall]
│ │ └─ ← [Return] 2
│ ├─ emit Liked(liker: 0x0000000000000000000000000000000000000400, liked: 0x0000000000000000000000000000000000000500)
│ └─ ← [Stop]
├─ [0] VM::prank(0x0000000000000000000000000000000000000400)
│ └─ ← [Return]
├─ [31014] LikeRegistry::likeUser{value: 1000000000000000000}(0x0000000000000000000000000000000000000600)
│ ├─ [627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000400) [staticcall]
│ │ └─ ← [Return] 1
│ ├─ [2627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000600) [staticcall]
│ │ └─ ← [Return] 3
│ ├─ emit Liked(liker: 0x0000000000000000000000000000000000000400, liked: 0x0000000000000000000000000000000000000600)
│ └─ ← [Stop]
├─ [0] VM::prank(0x0000000000000000000000000000000000000500)
│ └─ ← [Return]
├─ [639512] LikeRegistry::likeUser{value: 1000000000000000000}(0x0000000000000000000000000000000000000400)
│ ├─ [627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000500) [staticcall]
│ │ └─ ← [Return] 2
│ ├─ [627] SoulboundProfileNFT::profileToToken(0x0000000000000000000000000000000000000400) [staticcall]
│ │ └─ ← [Return] 1
│ ├─ emit Liked(liker: 0x0000000000000000000000000000000000000500, liked: 0x0000000000000000000000000000000000000400)
│ ├─ emit Matched(user1: 0x0000000000000000000000000000000000000500, user2: 0x0000000000000000000000000000000000000400)
│ ├─ [483834] → new MultiSigWallet@0xffD4505B3452Dc22f8473616d50503bA9E1710Ac
│ │ └─ ← [Return] 2193 bytes of code
│ ├─ [55] MultiSigWallet::receive()
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [0] VM::assertEq(3000000000000000000 [3e18], 3000000000000000000 [3e18], "Contract should incorrectly pool 3 ETH instead of 2 ETH") [staticcall]
│ └─ ← [Return]
├─ [561] LikeRegistry::userBalances(0x0000000000000000000000000000000000000400) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "Alice's balance should be reset to 0") [staticcall]
│ └─ ← [Return]
├─ [561] LikeRegistry::userBalances(0x0000000000000000000000000000000000000500) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "Bob's balance should be reset to 0") [staticcall]
│ └─ ← [Return]
├─ [2561] LikeRegistry::userBalances(0x0000000000000000000000000000000000000600) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "Charlie's ETH should NOT be drained into Alice-Bob match") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertFalse(false, "Exploit confirmed: Unrelated ETH is pooled") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.30ms (659.50µs CPU time)
  1. Alice likes Bob: 1 ETH

  2. Alice likes Charlie: 1 ETH

  3. Bob likes Alice: 1 ETH triggers match

  4. Test shows MultiSigWallet receives 3 ETH (3000000000000000000 wei)

The assert checks confirm:

  • Contract pooled 3 ETH instead of expected 2 ETH

  • All userBalances were zeroed

  • Charlie's ETH was indeed pooled into Alice-Bob match

Impact

  1. Funds sent by a user for likes that were not reciprocated (e.g., Alice’s like for Charlie) could be wrongfully pooled into a match with another user (e.g., Bob), effectively “stealing” those funds.

  2. Attackers could exploit this mechanism by intentionally matching with users who have sent likes to multiple parties, thereby capturing more funds than intended.

Tools Used

Manual Review

Recommendations

To resolve this vulnerability, the contract should be restructured so that ETH sent for a like is tracked on a per-recipient basis rather than being aggregated into a global balance for each user.

mapping(address => mapping(address => uint256)) public likeFunds;

In the likeUser function, the contract would update this mapping as follows:

likeFunds[msg.sender][liked] += msg.value;

Then, when a mutual match occurs, the matchRewards function should only pool the funds corresponding to the specific mutual like:

function matchRewards(address from, address to) internal {
uint256 fundsFrom = likeFunds[from][to];
uint256 fundsTo = likeFunds[to][from];
likeFunds[from][to] = 0;
likeFunds[to][from] = 0;
uint256 totalRewards = fundsFrom + fundsTo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
totalFees += matchingFees;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

This change ensures that only ETH specifically intended for the mutual match is pooled, preserving funds sent for other likes and preventing unintended cross-like pooling.

Updates

Appeal created

n0kto Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
udogodwin2k22 Submitter
4 months ago
n0kto Lead Judge
4 months ago
n0kto Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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