Last Man Standing

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

Reentrancy in `withdrawWinnings()` function in `Game.sol`

Root + Impact

Description

In the withdrawWinnings() function, a user is able to wihdraw their winnings stored in pendingWinnings. In a normal working process, the winnings are securely transferred and the internal state is updated.

In this case however, the contract updates the user’s pendingWinnings after performing the external call to msg.sender. This creates a reentrancy vulnerability, where a malicious contract can reenter the same function. As a result, the attacker could repeatedly withdraw more than their actual winnings. Although the withdrawWinnings() function is protected by the nonReentrant modifier, this only prevents reentering the same function, it does not protect against cross-function reentrancy.

If another function accesses or modifies the same shared state (pendingWinnings) and is not protected by nonReentrant modifier, an attacker can exploit this gap by reentering through that unprotected function during the external call.

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:

  • A user can deploy a contract with a fallback or receive function that reenters the withdrawWinnings() logic.

  • Another function can also accesses and modify pendingWinnings, thus increasing the risk for reentrancy.

Impact:

  • An attacker can repeatedly withdraw winnings before their balance is zeroed, draining the contract’s ETH.

Proof of Concept

Below is a simplified malicious contract that exploits the reentrancy vulnerability:


contract MaliciousAttacker {
Game public game;
bool public hasReentered;
constructor(address _game) {
game = Game(_game);
}
// Fallback is triggered
receive() external payable {
if (!hasReentered) {
hasReentered = true;
game.withdrawWinnings(); // Reentrancy call before state is updated
}
}
function attack() external {
game.withdrawWinnings(); // Initial call
}
// Assume this contract has winnings already
}

}

Recommended Mitigation

Use the checks-effects-interactions pattern. Update the internal state before making any external calls. This prevents reentrancy attacks even if the external call triggers a fallback function.

Updates

Appeal created

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