2025-02-datingdapp/audit/M-02.md
han 09ed9015e5
Some checks failed
CI / Foundry project (push) Has been cancelled
add valid finding, ai finding, and retro
2025-03-08 23:24:27 +07:00

8.6 KiB

M-02. Logic flaw in LikeRegistry::matchRewards can lead to users getting free dates

Valid medium from someone else.

Summary

LikeRegistry::matchRewards resets the matched users' userBalances. However, if a previously matched user gets liked and matched later on, the share multisig wallet will contain no funds from him. Technically, this scenario isn't possible due to a bug where userBalances is never updated with user funds, but the flawed logic is still there.

Vulnerability Details

Upon a match, LikeRegistry::matchRewards gets called.

function matchRewards(address from, address to) internal {
    uint256 matchUserOne = userBalances[from];
    uint256 matchUserTwo = userBalances[to];
    // [1]
    userBalances[from] = 0;
    userBalances[to] = 0;

    // [2]
    uint256 totalRewards = matchUserOne + matchUserTwo;
    // [3]
    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);

    // [4]
    // Send ETH to the deployed multisig wallet
    (bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
    require(success, "Transfer failed");
}

matchRewards will collect ([2]) all their previous like payments (minus a 10% fee [3]) and finally create a shared multisig wallet between the two users, which they can access for their first date ([4]). Note how userBalances is reset at [1]. Imagine the following scenario:

  1. bob likes alice
    • userBalance[bob] += 1 ETH
  2. bob likes angie
    • userBalance[bob] += 1 ETH
  3. alice likes bob (match)
    • userBalance[alice] += 1 ETH
    • reset of userBalance[alice] and userBalance[bob]
  4. angie likes alex
    • userBalance[angie] += 1 ETH
  5. angie likes tony
    • userBalance[angie] += 1 ETH
  6. angie likes bob (match)
    • userBalance[angie] += 1 ETH (total of 3 ETH)
    • userBalance[bob] is reset from bob's previous match with alice
  7. shared multisig wallet is created using only angie's funds

Proof of Concept

To demonstrate the aforementioned scenario, apply the following patch in LikeRegistry::likeUser in order to update userBalances properly,

function likeUser(address liked) external payable {
    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"
    );

+   userBalances[msg.sender] += msg.value;

    likes[msg.sender][liked] = true;
    emit Liked(msg.sender, liked);

    // Check if mutual like
    if (likes[liked][msg.sender]) {
        matches[msg.sender].push(liked);
        matches[liked].push(msg.sender);
        emit Matched(msg.sender, liked);
        matchRewards(liked, msg.sender);
    }
}

as well as a few logs in LikeRegistry::matchRewards:

function matchRewards(address from, address to) internal {
    uint256 matchUserOne = userBalances[from];
    uint256 matchUserTwo = userBalances[to];
@>  console.log("[LikeRegistry::matchRewards] matchUserOne:", matchUserOne);
@>  console.log("[LikeRegistry::matchRewards] matchUserTwo:", matchUserTwo);
    userBalances[from] = 0;
    userBalances[to] = 0;

    // ...
    
    // Deploy a MultiSig contract for the matched users
    MultiSigWallet multiSigWallet = new MultiSigWallet(payable(address(this)), from, to);
    // Send ETH to the deployed multisig wallet
    (bool success, ) = payable(address(multiSigWallet)).call{
        value: rewards
    }("");
    require(success, "Transfer failed");
@>  console.log("[LikeRegistry::matchRewards] multiSigWallet balance:", address(multiSigWallet).balance);
}

Finally, place test_UserCanGetFreeDates in testSoulboundProfileNFT.t.sol:

function test_UserCanGetFreeDates() public {
    address bob   = makeAddr("bob");
    address alice = makeAddr("alice");
    address angie = makeAddr("angie");
    address alex  = makeAddr("alex");
    address tony  = makeAddr("tony");

    vm.deal(bob, 10 ether);
    vm.deal(alice, 10 ether);
    vm.deal(angie, 10 ether);

    // mint a profile NFT for bob
    vm.prank(bob);
    soulboundNFT.mintProfile("Bob", 25, "ipfs://profileImage");

    // mint a profile NFT for alice
    vm.prank(alice);
    soulboundNFT.mintProfile("Alice", 25, "ipfs://profileImage");

    // mint a profile NFT for angie
    vm.prank(angie);
    soulboundNFT.mintProfile("Angie", 25, "ipfs://profileImage");

    // mint a profile NFT for alex
    vm.prank(alex);
    soulboundNFT.mintProfile("Alex", 25, "ipfs://profileImage");

    // mint a profile NFT for tony
    vm.prank(tony);
    soulboundNFT.mintProfile("Tony", 25, "ipfs://profileImage");

    // bob <3 alice
    vm.prank(bob);
    likeRegistry.likeUser{value: 1 ether}(alice);
    assertTrue(likeRegistry.likes(bob, alice));

    // bob <3 angie
    vm.prank(bob);
    likeRegistry.likeUser{value: 1 ether}(angie);
    assertTrue(likeRegistry.likes(bob, angie));

    console.log("====== FIRST MATCH ======");
    // alice <3 bob (match)
    vm.prank(alice);
    likeRegistry.likeUser{value: 1 ether}(bob);
    assertTrue(likeRegistry.likes(alice, bob));

    // angie <3 alex
    vm.prank(angie);
    likeRegistry.likeUser{value: 1 ether}(alex);
    assertTrue(likeRegistry.likes(angie, alex));

    // angie <3 tony
    vm.prank(angie);
    likeRegistry.likeUser{value: 1 ether}(tony);
    assertTrue(likeRegistry.likes(angie, tony));

    console.log("\n\n====== SECOND MATCH ======");
    // angie <3 bob (match)
    vm.prank(angie);
    likeRegistry.likeUser{value: 1 ether}(bob);
}

and run the test:

$ forge test --mt test_UserCanGetFreeDates -vvv
Ran 1 test for test/testSoulboundProfileNFT.t.sol:SoulboundProfileNFTTest
[PASS] test_UserCanGetFreeDates() (gas: 2274150)
Logs:
  ====== FIRST MATCH ======
  [LikeRegistry::matchRewards] matchUserOne: 2000000000000000000
  [LikeRegistry::matchRewards] matchUserTwo: 1000000000000000000
  [LikeRegistry::matchRewards] totalFees: 300000000000000000
  [LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000
  

====== SECOND MATCH ======
  [LikeRegistry::matchRewards] matchUserOne: 0
  [LikeRegistry::matchRewards] matchUserTwo: 3000000000000000000
  [LikeRegistry::matchRewards] totalFees: 600000000000000000
  [LikeRegistry::matchRewards] multiSigWallet balance: 2700000000000000000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.57ms (1.93ms CPU time)

Ran 1 test suite in 139.45ms (7.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Note how on the second match (between angie and bob), matchUserOne (which corresponds to bob) is 0. It was reset upon his match with alice.

Impact

Users can get free dates. The impact is low since this bug isn't technically feasible due to a bug in the LikeRegistry contract where userBalances isn't properly updated with user payments. The logic flaw remains though.

Tools used

Manual review, tests

Recommendations

Consider grouping ETH-like payments on a per-like basis instead of all together.

contract LikeRegistry is Ownable {

    // ...

    mapping(address => mapping(address => bool)) public likes;
    mapping(address => address[]) public matches;
-   mapping(address => uint256) public userBalances;
+   mapping(address => mapping(address => uint256)) public userBalances;


    function likeUser(address liked) external payable {
        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"
        );

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

        likes[msg.sender][liked] = true;
        emit Liked(msg.sender, liked);

        // ...
    }

    function matchRewards(address from, address to) internal {
-       uint256 matchUserOne = userBalances[from];
-       uint256 matchUserTwo = userBalances[to];
+       uint256 matchUserOne = userBalances[from][to];
+       uint256 matchUserTwo = userBalances[to][from];
-       userBalances[from][to] = 0;
-       userBalances[to][from] = 0;
+       userBalances[from][to] = 0;
+       userBalances[to][from] = 0;

        // ...
    }
}