Last Man Standing

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

Withdrawal DoS if Winner is Reverting Contract

Root + Impact

Description

  • Normal behavior:
    Winners should always be able to withdraw their winnings regardless of whether they are EOAs or smart contracts.

    Specific issue:
    The withdrawWinnings() function uses a low-level call to transfer ETH. If the recipient is a contract that reverts on ETH reception, the withdrawal fails permanently, locking the funds forever.

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}(""); // @> Fails if recipient reverts
require(success, "Game: Failed to withdraw winnings.");
pendingWinnings[msg.sender] = 0;
emit WinningsWithdrawn(msg.sender, amount);
}

Risk

Likelihood:

  • Contracts without receive/fallback functions participate in the game.

  • Contracts with buggy or restrictive ETH reception logic become winners.

Impact:

  • Winner's funds are permanently locked with no recovery mechanism.

  • No way to force transfer the funds to the rightful winner.

  • Breaks the core game promise that winners can claim their prize.

Proof of Concept

contract RevertingContract {
function claimThrone(Game game) external payable {
game.claimThrone{value: msg.value}();
}
// No receive() or fallback() - will revert on ETH transfer
}
function testWithdrawalDoSRevertingContract() public {
// Reverting contract claims throne and becomes winner
revertingContract.claimThrone{value: INITIAL_CLAIM_FEE}(game);
vm.warp(block.timestamp + GRACE_PERIOD + 1);
game.declareWinner();
assertTrue(game.pendingWinnings(address(revertingContract)) > 0, "Should have winnings");
// Withdrawal fails permanently
vm.startPrank(address(revertingContract));
vm.expectRevert("Game: Failed to withdraw winnings.");
game.withdrawWinnings();
vm.stopPrank();
// Funds remain locked forever
assertTrue(game.pendingWinnings(address(revertingContract)) > 0, "Winnings still locked");
}

Recommended Mitigation

Implement a pull payment pattern with fallback mechanism or allow owner to recover stuck funds:

+ mapping(address => bool) public withdrawalFailed;
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;
+
+ if (success) {
+ pendingWinnings[msg.sender] = 0;
+ emit WinningsWithdrawn(msg.sender, amount);
+ } else {
+ withdrawalFailed[msg.sender] = true;
+ emit WithdrawalFailed(msg.sender, amount);
+ }
}
+ // Allow owner to recover funds for failed withdrawals
+ function recoverFailedWithdrawal(address winner, address newRecipient) external onlyOwner {
+ require(withdrawalFailed[winner], "No failed withdrawal");
+ uint256 amount = pendingWinnings[winner];
+ pendingWinnings[winner] = 0;
+ withdrawalFailed[winner] = false;
+ payable(newRecipient).transfer(amount);
+ }
Updates

Appeal created

inallhonesty Lead Judge 30 days 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.

r4y4n3 Submitter
30 days ago
inallhonesty Lead Judge
29 days ago
inallhonesty Lead Judge 26 days 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.