Rock Paper Scissors

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

Player B Replacement Vulnerability

Summary

joinGameWithEth function doesn't explicitly change the game state from GameState.Created to another state after Player B joins. It only sets game.playerB = msg.sender. Player C could call joinGameWithEth with the same _gameId and replace Player B, as long as they send the correct bet amount.

Vulnerability Details

The joinGameWithEth function doesn't properly protect against overwriting of Player B's address:

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");
// No check that game.playerB == address(0) before assigning
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}

The function doesn't check if Player B already exists, nor does it change the game state after Player B joins.

Impact

A malicious actor (Player C) can monitor the blockchain for recently joined games and replace legitimate Player B by calling joinGameWithEth with the same game ID. This allows:

  • Front-running attacks to steal desirable games from legitimate players

  • Original Player B loses their position in the game

  • Original Player B's ETH is stuck in the contract with no easy way to recover it

PoC

// 1. Player A creates a game
uint256 gameId = game.createGameWithEth{value: 1 ether}(3, 300);
// 2. Player B joins the game
vm.prank(playerB);
game.joinGameWithEth{value: 1 ether}(gameId);
// 3. Player C can replace Player B
vm.prank(playerC);
game.joinGameWithEth{value: 1 ether}(gameId);
// 4. Verify playerC is now the second player
(address playerA, address secondPlayer,,,,,,,,,,,,,,) = game.games(gameId);
assertEq(secondPlayer, playerC); // Player B has been replaced

Tools Used

Manual code review

Recommendations

Add a check to ensure Player B hasn't already joined or update the game state in the 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), "Game already has two players"); // << --
game.playerB = msg.sender;
emit PlayerJoined(_gameId, msg.sender);
}
Updates

Appeal created

m3dython Lead Judge 4 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.