Summary
The matchRewards()
function violates the Checks-Effects-Interactions pattern by modifying states before performing ETH transfers, leading to permanent loss of funds.
Vulnerability Details
In LikeRegistry.sol
, the matchRewards
function:
Sets the users' balances to zero before the transfer
Has no rollback mechanism in case of failure
Modifies critical state before external interactions
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");
}
Impact
Permanent Loss of Funds : If the transfer fails, the balances are already zero and the funds remain locked in the contract
No Recovery Mechanism : No way to recover the funds in case of failure
Reentrancy Risk : State modifications before external calls could be exploited
Severity: HIGH - Permanent loss of funds with high probability of occurrence
Tools Used
Proof of Concept
function testViolationOfCEI() public {
vm.deal(alice, 2 ether);
vm.deal(bob, 2 ether);
vm.prank(alice);
likeRegistry.likeUser{value: 1 ether}(bob);
vm.startPrank(bob);
vm.expectEmit(true, true, false, true);
emit Liked(bob, alice);
vm.expectEmit(true, true, false, true);
emit Matched(bob, alice);
likeRegistry.likeUser{value: 1 ether}(alice);
vm.stopPrank();
vm.prank(alice);
address[] memory aliceMatches = likeRegistry.getMatches();
assertEq(aliceMatches.length, 1, "Alice should have one match");
assertEq(aliceMatches[0], bob, "Alice's match should be Bob");
assertEq(likeRegistry.userBalances(alice), 0, "Alice balance should be 0");
assertEq(likeRegistry.userBalances(bob), 0, "Bob balance should be 0");
assertEq(address(likeRegistry).balance, 2 ether, "ETH should still be in contract");
}
Recommendations
Follow the Checks-Effects-Interactions pattern:
function matchRewards(address from, address to) internal {
uint256 matchUserOne = userBalances[from];
uint256 matchUserTwo = userBalances[to];
uint256 totalRewards = matchUserOne + matchUserTwo;
uint256 matchingFees = (totalRewards * FIXEDFEE) / 100;
uint256 rewards = totalRewards - matchingFees;
MultiSigWallet multiSigWallet = new MultiSigWallet(from, to);
uint256 initialBalance = address(multiSigWallet).balance;
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
require(
address(multiSigWallet).balance == initialBalance + rewards,
"ETH not received"
);
userBalances[from] = 0;
userBalances[to] = 0;
totalFees += matchingFees;
}
Use OpenZeppelin's Address.sendValue()
which includes additional safety checks:
using Address for address payable;
function matchRewards(address from, address to) internal {
payable(address(multiSigWallet)).sendValue(rewards);
}
Implement a pull-over-push pattern for withdrawals:
mapping(address => uint256) public pendingWithdrawals;
function matchRewards(address from, address to) internal {
pendingWithdrawals[address(multiSigWallet)] = rewards;
emit WithdrawalReady(address(multiSigWallet), rewards);
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No pending withdrawal");
pendingWithdrawals[msg.sender] = 0;
payable(msg.sender).sendValue(amount);
}