Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: medium
Valid

Unrecoverable ETH Sink via Unrestricted Receive Function

Vulnerability Details

The contract includes a receive() function, allowing anyone to send ETH directly to the contract at any time. However, the contract does not provide a mechanism to withdraw arbitrary ETH that may be accidentally sent this way. The only withdrawal logic available is within withdrawFees(), which exclusively manages protocol fee earnings and not unaccounted ETH. Smart contracts can accumulate ETH not just through gameplay, but also via accidental transfers, misdirected transactions, or purposeful deposits by third parties. This contract has a receive() fallback that accepts ETH:

receive() external payable {}

But there is no corresponding function to allow the admin or any other entity to recover ETH that is not explicitly part of the protocol fee structure. The only withdrawal pathway is through:

function withdrawFees() external onlyOwner {
uint256 amount = protocolFees;
protocolFees = 0;
(bool success, ) = owner().call{value: amount}("");
require(success, "Transfer failed");
}

This function only transfers the tracked protocolFees, not the contract’s entire ETH balance.

Thus, any ETH that enters the contract outside of the fee collection logic is stuck permanently.

Impact

  • Trapped ETH: Any ETH sent accidentally or maliciously becomes irretrievable.

  • Protocol Mismanagement: Lack of access to all held funds may be interpreted as poor contract design or negligence.

  • User Confusion: Individuals who mistakenly send ETH may expect recovery but find no recourse.

The problem worsens over time as more ETH may be sent intentionally (to test or spam), causing value to be lost forever unless a recovery mechanism is introduced.

Tools Used

  • Manual Review: The issue was identified through a careful inspection of the contract’s logic surrounding fund reception (receive()) and withdrawal (withdrawFees()).

Recommended Mitigation Steps

To resolve this issue and ensure good fund management practices:

  1. Add a generic ETH withdrawal function:

    function recoverStrayETH(address payable to, uint256 amount) external onlyOwner {
    require(address(this).balance >= amount, "Insufficient ETH");
    (bool success, ) = to.call{value: amount}("");
    require(success, "Transfer failed");
    }
  2. Log ETH received via receive(): This helps in monitoring and alerts:

    event Received(address sender, uint256 amount);
    receive() external payable {
    emit Received(msg.sender, msg.value);
    }
  3. Consider adding access controls to limit ETH recovery to the contract owner or multisig.

  4. Document behavior clearly so users know not to send ETH directly unless intended for gameplay or fees.

Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Orphaned ETH due to Unrestricted receive() or Canceled Game

ETH sent directly to the contract via the receive function or after a canceled game becomes permanently locked

Support

FAQs

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