Description:
A third account, other than playerA or playerB can call RockPaperScissors::joinGameWithEth or RockPaperScissors::joinGameWithToken and pay the bet after playerB has joined and become the new playerB.
Joining the game only requires the game.state to be Created, which will remain until the first move has been committed, allowing multiple calls to the function.
Impact:
A new player replacing playerB will result in the loss of the bet money for the original player.
Proof of Code:
This test proves that playerA can create an ETH game, playerB can join, then playerC can also join and replace playerB, all three having spent the bet money:
function testAuditJoinETHGameWithPlayerC() public {
vm.prank(playerA);
uint256 playerABalanceBefore = playerA.balance;
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.prank(playerB);
uint256 playerBBalanceBefore = playerB.balance;
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.prank(playerC);
uint256 playerCBalanceBefore = playerC.balance;
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
(address player_A, address player_B,,,,,,,,,,,,,,RockPaperScissors.GameState state) = game.games(gameId);
assertEq(player_A, address(playerA));
assertEq(player_B, address(playerC));
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
assertEq(playerABalanceBefore - playerA.balance, BET_AMOUNT);
assertEq(playerBBalanceBefore - playerB.balance, BET_AMOUNT);
assertEq(playerCBalanceBefore - playerC.balance, BET_AMOUNT);
}
The same can also be done with a token game:
function testAuditJoinTokenGameWithPlayerC() public {
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
vm.startPrank(playerC);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
(address player_A, address player_B,,,,,,,,,,,,,,RockPaperScissors.GameState state) = game.games(gameId);
assertEq(player_A, address(playerA));
assertEq(player_B, address(playerC));
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
assertEq(token.balanceOf(playerA), 9);
assertEq(token.balanceOf(playerB), 9);
assertEq(token.balanceOf(playerC), 9);
}
Recommended Mitigation:
Require playerB to be a zero address at the time of calling for both join functions.
function joinGameWithEth(uint256 _gameId) external payable {
Game storage game = games[_gameId];
require(game.state == GameState.Created, "Game not open to join");
require(game.playerA != msg.sender, "Cannot join your own game");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(msg.value == game.bet, "Bet amount must match creator's bet"); //
+ require(game.playerB == address(0), "Player B has already joined");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
function joinGameWithToken(uint256 _gameId) external {
Game storage game = games[_gameId];
require(game.state == GameState.Created, "Game not open to join");
require(game.playerA != msg.sender, "Cannot join your own game");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(game.bet == 0, "This game requires ETH bet");
require(winningToken.balanceOf(msg.sender) >= 1, "Must have winning token");
+ require(game.playerB == address(0), "Player B has already joined");
// Transfer token to contract
winningToken.transferFrom(msg.sender, address(this), 1);
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}