Rock Paper Scissors

First Flight #38
Beginner FriendlySolidity
100 EXP
View results
Submission Details
Severity: high
Valid

A third player can join and replace `playerB` if no commits have been made yet, making `playerB` lose their bet

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 {
// Create a game
vm.prank(playerA);
uint256 playerABalanceBefore = playerA.balance;
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// Player B joins the game
vm.prank(playerB);
uint256 playerBBalanceBefore = playerB.balance;
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// Player C joins the game
vm.prank(playerC);
uint256 playerCBalanceBefore = playerC.balance;
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// Verify game state
(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 {
// Create a game with token
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// Player B joins the game
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// Player C joins the game
vm.startPrank(playerC);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// Verify game state
(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);
}
Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Absence of State Change on Join Allows Player B Hijacking

Game state remains Created after a player joins

Support

FAQs

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