Last Man Standing

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

Inability to Withdraw Winnings for Contract Winners without receive or fallback functions.

Root + Impact

Description

  • In normal behavior, a winner can call withdrawWinnings() to transfer their pending ETH winnings from the contract to their own address.

  • However, when the currentKing is a smart contract with no receive() or fallback() function, the low-level call in withdrawWinnings() will fail, making it impossible to withdraw the funds. There is also no alternative withdrawTo(address) method that would allow redirecting winnings to an externally owned account (EOA), potentially causing ETH to become permanently stuck.

function withdrawWinnings() external nonReentrant {
uint256 amount = pendingWinnings[msg.sender];
require(amount > 0, "Game: No winnings to withdraw.");
(bool success, ) = payable(msg.sender).call{value: amount}("");
@> require(success, "Game: Failed to withdraw winnings.");
pendingWinnings[msg.sender] = 0;
emit WinningsWithdrawn(msg.sender, amount);
}

Risk

Likelihood:

  • This occurs when a contract becomes king and later wins the game.

  • The winning contract lacks a receive() or fallback() function and cannot accept plain ETH transfers.

Impact:

  • Winnings cannot be withdrawn, leaving ETH permanently stuck in the contract.

  • Neither the owner nor the winner can recover or redirect the funds unless the winning contract self-destructs or is upgraded.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/Game.sol"; // Adjust path to your Game contract
contract GameWithdrawTest is Test {
Game game;
address alice = address(0x1); // EOA
address nonReceivableContract;
function setUp() public {
game = new Game(1 ether, 1 days, 10, 10); // initialClaimFee=1 ETH, gracePeriod=1 day, feeIncrease=10%, platformFee=10%
vm.deal(alice, 10 ether);
nonReceivableContract = address(new NonReceivableContract());
vm.deal(nonReceivableContract, 10 ether);
}
function testWithdrawFailsForNonReceivableContract() public {
// Non-receivable contract claims the throne
vm.prank(nonReceivableContract);
NonReceivableContract(nonReceivableContract).claim(payable(address(game)));
assertEq(game.currentKing(), nonReceivableContract, "Contract should be the current king");
// Fast forward past grace period and declare winner
vm.warp(block.timestamp + 25 hours);
game.declareWinner();
// Verify winnings are assigned (0.9 ETH due to 10% platform fee)
assertEq(game.pendingWinnings(nonReceivableContract), 0.9 ether, "Winnings should be 0.9 ETH after platform fee");
// Attempt to withdraw winnings, expect revert
vm.prank(nonReceivableContract);
vm.expectRevert(bytes("Game: Failed to withdraw winnings."));
game.withdrawWinnings();
}
}
contract NonReceivableContract {
function claim(address payable game) external {
Game(game).claimThrone{value: 1 ether}();
}
}
  • The test demonstrates that a non-receivable contract (lacking a receive or fallback function) can claim the throne but fails to withdraw winnings due to a revert in withdrawWinnings, as the ETH transfer to msg.sender fails, leaving funds locked.

Recommended Mitigation

// Add to Game.sol
+ /**
+ * @dev Allows the winner to withdraw their pending winnings to a specified address.
+ * @param to The address to receive the winnings.
+ */
+ function withdrawWinningsTo(address payable to) external nonReentrant {
+ uint256 amount = pendingWinnings[msg.sender];
+ require(amount > 0, "Game: No winnings to withdraw.");
+ require(to != address(0), "Game: Invalid recipient address.");
+
+ pendingWinnings[msg.sender] = 0;
+ (bool success, ) = to.call{value: amount}("");
+ require(success, "Game: Failed to withdraw winnings.");
+
+ emit WinningsWithdrawn(to, amount);
+ }
  • Add a withdrawWinningsTo(address payable to) function to allow winners to specify an alternate recipient address for their winnings, enabling successful withdrawals even if the winner is a contract without a receive or fallback function.

Updates

Appeal created

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

Doss by reverting contract / contract that doesn't implement receive.

The only party affected is the "attacker". Info at best.

Support

FAQs

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