Last Man Standing

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

Accidental ETH Transfers Are Permanently Locked Due to Unprotected receive Function

Root + Impact

Description

  • The contract accepts direct ETH transfers via an implicit receive() function but lacks:

    1. A withdrawal mechanism for accidentally sent funds

    2. Integration with the game's accounting system (pot/platform fees)

    3. Warnings in documentation about irreversible transfers


// Root cause in the codebase with @> marks to highlight the relevant section
@> receive() external payable {}

Risk

Likelihood: Medium

  • Reason : Users frequently interact with contracts via wallet interfaces that enable direct ETH transfers by default.

Impact:

  • Permanent Fund Loss: ETH sent directly to the contract becomes irretrievable.

  • Accounting Mismatch: Contract balance may exceed the sum of pot + platformFeesBalance


Proof of Concept

Output:

Logs:

Initial contract balance: 0
Initial pot: 0
Initial platform fees: 0
Contract balance after accidental ETH: 1000000000000000000
Pot after accidental ETH: 0
Platform fees after accidental ETH: 0
Pot after claim: 95000000000000000
Platform fees after claim: 5000000000000000
Contract balance after claim: 1100000000000000000
Contract balance after player1 withdraws winnings: 1005000000000000000
Contract balance after owner withdraws platform fees: 1000000000000000000

function testReceiveFunction() public {
// Step 1: Verify initial state
uint256 initialBalance = address(game).balance;
uint256 initialPot = game.pot();
uint256 initialPlatformFees = game.platformFeesBalance();
uint256 initialWinningsPlayer1 = game.pendingWinnings(player1);
assertEq(initialBalance, 0, "Initial contract balance should be 0");
console2.log("Initial contract balance: %s", initialBalance);
assertEq(initialPot, 0, "Initial pot should be 0");
assertEq(initialPlatformFees, 0, "Initial platform fees should be 0");
console2.log("Initial pot: %s", initialPot);
console2.log("Initial platform fees: %s", initialPlatformFees);
assertEq(initialWinningsPlayer1, 0, "Initial winnings for player1 should be 0");
// Step 2: Player1 accidentally sends 1 ETH to contract
vm.prank(player1);
(bool success, ) = address(game).call{value: 1 ether}("");
assertTrue(success, "Direct ETH transfer should succeed");
// Step 3: Verify contract balance increases, but state variables unchanged
assertEq(address(game).balance, 1 ether, "Contract balance should increase by 1 ETH");
console2.log("Contract balance after accidental ETH: %s", address(game).balance);
assertEq(game.pot(), initialPot, "Pot should not increase");
assertEq(game.platformFeesBalance(), initialPlatformFees, "Platform fees should not increase");
console2.log("Pot after accidental ETH: %s", game.pot());
console2.log("Platform fees after accidental ETH: %s", game.platformFeesBalance());
assertEq(game.pendingWinnings(player1), initialWinningsPlayer1, "Pending winnings should not increase");
// Step 4: Player1 claims throne to become king
vm.prank(player1);
game.claimThrone{value: INITIAL_CLAIM_FEE}();
assertEq(game.currentKing(), player1, "Player1 should be current king");
assertEq(game.pot(), INITIAL_CLAIM_FEE * 95 / 100, "Pot should increase by claim amount minus platform fee");
assertEq(game.platformFeesBalance(), INITIAL_CLAIM_FEE * 5 / 100, "Platform fees should increase");
console2.log("Pot after claim: %s", game.pot());
console2.log("Platform fees after claim: %s", game.platformFeesBalance());
assertEq(address(game).balance, 1 ether + INITIAL_CLAIM_FEE, "Contract balance should include accidental ETH and claim");
console2.log("Contract balance after claim: %s", address(game).balance);
// Step 5: Declare winner and attempt to withdraw winnings
vm.warp(block.timestamp + GRACE_PERIOD + 1);
vm.prank(player2);
game.declareWinner();
assertEq(game.pendingWinnings(player1), INITIAL_CLAIM_FEE * 95 / 100, "Pending winnings should equal pot");
vm.prank(player1);
game.withdrawWinnings();
assertEq(game.pendingWinnings(player1), 0, "Pending winnings should be reset");
assertEq(address(game).balance, 1 ether + (INITIAL_CLAIM_FEE * 5 / 100), "Contract balance should retain accidental ETH and platform fees");
console2.log("Contract balance after player1 withdraws winnings: %s", address(game).balance);
// Step 6: Owner attempts to withdraw platform fees
vm.prank(deployer);
game.withdrawPlatformFees();
assertEq(game.platformFeesBalance(), 0, "Platform fees should be reset");
assertEq(address(game).balance, 1 ether, "Contract balance should retain only accidental ETH");
console2.log("Contract balance after owner withdraws platform fees: %s", address(game).balance);
// Step 7: Verify accidental ETH remains unallocated
assertEq(game.pot(), 0, "Pot should remain 0");
assertEq(game.platformFeesBalance(), 0, "Platform fees should remain 0");
assertEq(game.pendingWinnings(player1), 0, "Pending winnings should remain 0");
}

Recommended Mitigation

  1. Revert Unexpected ETH.

  1. Or add Rescue Function.

- remove this code
+ add this code
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.