Last Man Standing

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

Incorrect State Update Order in withdrawWinnings() Creates Reentrancy Risk

Incorrect State Update Order in withdrawWinnings() Creates Reentrancy Risk

The root cause is the violation of the Checks-Effects-Interactions pattern. The "Interaction" (sending ETH) happens before the "Effect" (updating the pendingWinnings balance). The @> markers highlight this incorrect ordering.

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}(""); // Interaction first...
require(success, "Game: Failed to withdraw winnings.");
@> pendingWinnings[msg.sender] = 0; // ...Effect second. This is the wrong order.
emit WinningsWithdrawn(msg.sender, amount);
}

Description

  • The withdrawWinnings() function does not adhere to the widely accepted Checks-Effects-Interactions (CEI) security pattern. The function transfers ETH to the user via an external .call() before it updates the user's internal accounting by setting their pendingWinnings to zero. Although the function is protected by a nonReentrant modifier, relying solely on a mutex while violating the CEI pattern is considered bad practice and can be risky in complex integrations. Should the nonReentrant guard ever be removed or contain a flaw, this incorrect ordering would immediately open a classic reentrancy attack vector.

Risk

Likelihood:

  • The presence of the nonReentrant guard prevents a simple, direct reentrancy attack. However, the underlying pattern is flawed. Exploitation would require a sophisticated scenario that bypasses the mutex, or a future code change where a developer might remove the guard without realizing the severity of the incorrect state update order.

Impact:

  • If the reentrancy guard were bypassed, a malicious contract acting as a winner could repeatedly call withdrawWinnings() within a single transaction before its balance is cleared. This would allow the attacker to drain more funds than they are entitled to, potentially stealing the entire contract balance, including the prize pot and accumulated platform fees.


Proof of Concept

An attacker could deploy a malicious contract that, upon receiving ETH, immediately calls back into the Game contract to withdraw funds again.

  1. The attacker deploys the Attacker contract and uses it to win the game.

  2. The attacker calls Attacker.startAttack(), which in turn calls Game.withdrawWinnings().

  3. The Game contract sends ETH to the Attacker contract.

  4. The Attacker contract's receive() function is triggered, which immediately calls Game.withdrawWinnings() again.

  5. If the nonReentrant guard were absent, this second call would succeed because pendingWinnings for the attacker has not yet been set to 0, allowing for a second withdrawal.

This PoC demonstrates the flawed structure that the nonReentrant guard is currently mitigating. The robust, long-term solution is to fix the underlying pattern.

contract Attacker {
Game public gameContract;
constructor(address gameAddress) {
gameContract = Game(gameAddress);
}
function startAttack() external {
gameContract.withdrawWinnings();
}
// This function is called when the contract receives ETH
receive() external payable {
// If the nonReentrant guard were bypassed or removed,
// this would allow draining the contract.
if (address(gameContract).balance > 0) {
gameContract.withdrawWinnings();
}
}
}

Recommended Mitigation

Refactor the withdrawWinnings() function to strictly follow the Checks-Effects-Interactions pattern. The state must be updated (Effect) before the external call to transfer ETH (Interaction).

- (bool success, ) = payable(msg.sender).call{value: amount}("");
- require(success, "Game: Failed to withdraw winnings.");
-
- pendingWinnings[msg.sender] = 0;
+ pendingWinnings[msg.sender] = 0; // Effect: Update state first.
+
+ (bool success, ) = payable(msg.sender).call{value: amount}(""); // Interaction: Send ETH last.
+ require(success, "Game: Failed to withdraw winnings.");
Updates

Appeal created

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

Game::withdrawWinnings reentrancy

We only audit the current code in scope. We cannot make speculation with respect to how this codebase will evolve in the future. For now there is a nonReentrant modifier which mitigates any reentrancy. CEI is a good practice, but it's not mandatory. Informational

Support

FAQs

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