Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Impact: high
Likelihood: low
Invalid

Inconsistent Balance: ETH Received Outside Game Logic Is Unaccounted

Root + Impact

Description

  • The contract implements a receive() function, allowing ETH to be sent directly to it without invoking any game logic.

receive() external payable {}

However, this ETH is not added to any internal variables like pot, platformFeesBalance, or pendingWinnings. At the same time, the contract exposes a getContractBalance() function that reads the raw ETH balance:

/**
* @dev Returns the current balance of the contract (should match the pot plus platform fees unless payouts are pending).
*/
function getContractBalance() public view returns (uint256) {
return address(this).balance;
}

This creates a false sense of correctness: external tools or frontend UIs may compare getContractBalance() to internal values (e.g., pot + fees) and assume they match. But ETH sent via receive() causes silent drift, breaking this assumption.

Risk

Likelihood:

  • Any user, wallet, or contract using .send() or .transfer() (or mistakenly pasting the contract address in MetaMask) will send ETH via receive().

  • This ETH will increase address(this).balance but not be included in any game-related accounting.

Impact:

  • getContractBalance() no longer reflects only accounted funds — off-chain monitors may wrongly trust it.

  • ETH can become permanently locked: it's not part of the pot, not withdrawable by the platform, and not claimable by players.

  • Auditors, UIs, or dashboards may display incorrect stats or mislead users about actual claimable or reserved balances.

Proof of Concept

ETH sent this way increases the contract balance but doesn't affect pot or platformFeesBalance. This creates a discrepancy between the actual contract balance and the accounting system, potentially leading to permanent loss of funds that can never be claimed by winners.

function testReceiveSkewsContractBalance() public {
// Contract starts with 0 ETH, no game activity
assertEq(address(game).balance, 0);
// Send 1 ether directly via receive()
vm.deal(player1, 1 ether);
vm.prank(player1);
(bool success,) = address(game).call{value: 1 ether}("");
require(success, "Transfer failed");
// Check that internal accounting hasn't changed
assertEq(game.pot(), 0, "Pot should not increase");
assertEq(game.platformFeesBalance(), 0, "Fees should not increase");
// Check that balance is now 1 ether
assertEq(game.getContractBalance(), 1 ether, "ETH exists but is unaccounted");
// This ETH is now stuck with no path for recovery
}

Recommended Mitigation

1: Remove receive() if not needed

If there's no legitimate reason for untagged ETH to be sent

- receive() external payable {}
+ // Remove the receive() function

2: Account for received ETH (e.g., add to pot or fees)

If allowing direct ETH transfers is intentional:

receive() external payable {
pot += msg.value;
emit UntrackedETHReceived(msg.sender, msg.value);
}
Updates

Appeal created

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Direct ETH transfers - User mistake

There is no reason for a user to directly send ETH or anything to this contract. Basic user mistake, info, invalid according to CH Docs.

Support

FAQs

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