Rock Paper Scissors

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

Missing Player B Existence Check in `RockPaperScissors::joinGameWithEth` Enables State Corruption via Unauthorized Participant Overwrite

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 {
// add a third player; playerC
address playerC = makeAddr("playerC");
vm.deal(playerC, 10 ether);
// player A creates game with token
vm.startPrank(playerA);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// player B joins game
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// assertions
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerB);
// player C tries to join the game
vm.startPrank(playerC);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// assertions
(address storedPlayerA2, address storedPlayerB2,,,,,,,,,,,,,, RockPaperScissors.GameState state2) =
game.games(gameId);
assertEq(storedPlayerA2, playerA);
assertEq(storedPlayerB2, playerC); // player C overwrites player B
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);
}
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.