Last Man Standing

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

Funds can be accidentally locked in the contract

Root + Impact

Description

  • The withdrawWinnings function
    should allow declared winners to withdraw
    their prize money by transferring ETH from the
    contract to the winner's address, then
    clearing their pendingWinnings balance.

  • When the ETH transfer fails
    (due to the recipient being a contract without
    a receive/fallback function, or a contract
    that rejects ETH transfers), the entire
    transaction reverts, leaving the winner's
    funds permanently locked in pendingWinnings
    with no alternative withdrawal mechanism or
    admin recovery function available.

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:

  • Users deploy smart contracts to interact
    with games without implementing
    receive/fallback functions, which is a common
    development oversight

  • Malicious actors can intentionally deploy
    contracts that reject ETH transfers after
    winning to permanently lock funds from all
    participating players

Impact:

  • Winner's prize money becomes permanently
    unrecoverable, affecting funds contributed by
    all players in that round

  • Contract accumulates stuck funds over time
    with no admin recovery mechanism, reducing the
    effective prize pool for future legitimate
    winners

Proof of Concept

The following PoC demonstrates how funds can be accidentially locked in the game contract. The test and additial smart contract should be placed in the Game.t.sol file.

function testUnaware_ContractWithoutReceiveFunction() public {
// Initialize a new game for this test
vm.prank(deployer);
Game newGame = new Game(
INITIAL_CLAIM_FEE,
GRACE_PERIOD,
FEE_INCREASE_PERCENTAGE,
PLATFORM_FEE_PERCENTAGE
);
// Player 1 claims throne first
vm.prank(player1);
newGame.claimThrone{value: INITIAL_CLAIM_FEE}();
console2.log("Player 1 claimed throne, new fee:", newGame.claimFee());
// Player 2 claims throne next (fee increased)
uint256 currentFee = newGame.claimFee();
vm.prank(player2);
newGame.claimThrone{value: currentFee}();
console2.log("Player 2 claimed throne, new fee:", newGame.claimFee());
// Player 3 uses smart contract without receive function
UnawareContract unawareContract = new UnawareContract();
vm.deal(address(unawareContract), 2 ether);
// Smart contract claims throne (becomes final winner)
currentFee = newGame.claimFee();
vm.prank(address(unawareContract));
unawareContract.claimThrone{value: currentFee}(newGame);
console2.log(
"Smart contract claimed throne, current king:",
newGame.currentKing()
);
// Wait for grace period to end
vm.warp(block.timestamp + GRACE_PERIOD + 1);
// Declare winner (smart contract wins)
newGame.declareWinner();
// Verify smart contract has pending winnings
uint256 winnings = newGame.pendingWinnings(address(unawareContract));
assertGt(winnings, 0);
console2.log("Smart contract has winnings:", winnings);
console2.log("Total pot accumulated from 3 players");
// Try to withdraw winnings using the contract's function - this will fail due to no receive function
vm.expectRevert("Game: Failed to withdraw winnings.");
unawareContract.withdrawWinnings(newGame);
// Funds remain stuck in pendingWinnings
assertEq(newGame.pendingWinnings(address(unawareContract)), winnings);
console2.log("Funds permanently stuck due to missing receive function");
// The contract owner realizes the mistake but can't fix it
// No way to recover the stuck funds from all 3 players' contributions
console2.log(
"User error: Contract deployed without receive/fallback function"
);
console2.log("Result: Prize money from all players permanently lost");
}
}
contract UnawareContract {
// User forgot to implement receive() or fallback() function
function claimThrone(Game game) external payable {
game.claimThrone{value: msg.value}();
}
function withdrawWinnings(Game game) external {
game.withdrawWinnings();
}
}

Recommended Mitigation

Consider to implement an admin recovery function to handle
stuck funds. This allows the contract owner to redirect
stuck funds to a new address (such as an EOA
controlled by the original winner) when
withdrawal failures occur.

Centralization Risk: This approach introduces
admin control over user funds, which creates a
single point of failure and requires users to
trust the contract owner. Consider
implementing additional safeguards such as a
time delay, multi-signature requirements, or
community governance for fund recovery
decisions to mitigate centralization concerns.

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.