Description
The joinGameWithEth function allows any user to join an existing game by supplying the correct gameId and bet amount. However, there is no check to ensure that the playerB slot has not already been filled. This allows any user to repeatedly overwrite the playerB address for a given gameId, leading to corrupted or inconsistent game state.
Since playerB is set unconditionally, this behavior breaks the expected one-to-one mapping between players and games. It also introduces the risk of race conditions where multiple users can attempt to join the same game at the same time — the last transaction mined wins, overwriting the others silently.
function joinGameWithEth(bytes32 gameId, Hand _hand) external payable {
Game storage game = games[gameId];
require(game.playerA != address(0), "Game does not exist");
require(msg.value == game.betAmount, "Incorrect bet amount");
game.playerB = msg.sender;
game.handB = _hand;
emit GameJoined(gameId, msg.sender);
}
There is no check like require(game.playerB == address(0)), so this function allows playerB to be overwritten any number of times before playerA reveals their hand.
Impact
State Corruption and Game Hijacking: A malicious user can repeatedly overwrite the playerB field, breaking the integrity of the game logic.
Loss of Funds: Original playerB will loose their staked ETH as there is no method to get the funds back after being overwritten.
Proof of Concepts
function testPlayerBOverwriteInJoinGameWithEth() public {
address playerC = makeAddr("playerC");
vm.deal(playerC, 10 ether);
vm.startPrank(playerA);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerB);
vm.startPrank(playerC);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
(address storedPlayerA2, address storedPlayerB2,,,,,,,,,,,,,, RockPaperScissors.GameState state2) =
game.games(gameId);
assertEq(storedPlayerA2, playerA);
assertEq(storedPlayerB2, playerC);
assertEq(uint256(state2), uint256(RockPaperScissors.GameState.Created));
}
1. Player A creates a game (gameId = 0xabc...)
2. Player B joins with 1 ETH
3. Player C calls joinGameWithEth with same gameId, also sending 1 ETH
Result: game.playerB now equals Player C, not B
Recommended mitigation
function joinGameWithEth(uint256 _gameId) external payable {
Game storage game = games[_gameId];
require(game.state == GameState.Created, "Game not open to join");
+ require(game.playerB == address(0), "Game already joined");
require(game.playerA != msg.sender, "Cannot join your own game");
require(block.timestamp <= game.joinDeadline, "Join deadline passed");
// @audit - should check if game was created using createGameWithEth
// can join game created using createGameWithToken without spending ETH or Tokens
// require(game.bet > 0, "Game Was Created With Token");
require(msg.value == game.bet, "Bet amount must match creator's bet"); // must be 1ETH
game.playerB = msg.sender;
// game.GameState = Joined
emit PlayerJoined(_gameId, msg.sender);
}