Rock Paper Scissors

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

Potential Score Overflow Due to `uint8` Score Variables

Summary

The RockPaperScissors.sol contract uses uint8 variables (scoreA, scoreB) within the Game struct to track player scores across multiple turns. A uint8 has a maximum value of 255. The game creation functions (createGameWithEth, createGameWithToken) allow setting the totalTurns for a game without an upper limit related to this score capacity. If a game is configured with a high number of turns (e.g., > 255) and one player achieves 256 or more wins, the score increment operation (game.scoreA++ or game.scoreB++) within the _determineWinner function will trigger an arithmetic overflow. Due to Solidity version 0.8.x's default checked arithmetic, this overflow will cause the transaction to revert, leading to a Denial of Service (DoS) for the game's completion and potentially locking the staked funds.

Vulnerability Details

  1. Data Type Limitation: The scoreA and scoreB members of the Game struct are defined as uint8, restricting their maximum value to 255.

    struct Game {
    // ...
    uint8 scoreA; // Max value 255
    uint8 scoreB; // Max value 255
    // ...
    }
  2. Score Increment: The _determineWinner function increments the winner's score using the ++ operator.

    function _determineWinner(uint256 _gameId) internal {
    // ... logic to determine winner ...
    if (/* Player A wins */) {
    game.scoreA++; // Potential overflow if scoreA is 255
    // ...
    } else {
    // Player B wins
    game.scoreB++; // Potential overflow if scoreB is 255
    // ...
    }
    // ...
    }
  3. Checked Arithmetic: The contract uses pragma solidity ^0.8.13;. Versions 0.8.0 and higher have built-in overflow and underflow checks for arithmetic operations. An attempt to increment a uint8 variable from 255 to 256 will result in a revert.

  4. Lack of Input Validation: The createGameWithEth and createGameWithToken functions validate that _totalTurns is positive and odd but do not impose an upper limit based on the uint8 score capacity. A user can create a game with, for example, 301 turns.

  5. Exploitation Scenario: If a game is created with totalTurns = 301, and Player A wins 256 turns before the game concludes, the next time Player A wins a turn, _determineWinner will attempt game.scoreA++ on the value 255. This operation will revert due to overflow.

Proof Of Concept

The provided test case demonstrates this vulnerability

// ==================== SCORE OVERFLOW TEST ====================
/**
* @notice Tests revert on score overflow when scoreA reaches 255 and tries to increment.
* Uses vm.store to set up the pre-overflow state.
*/
function testScoreOverflow_PlayerA() public {
// --- Setup: Create a game with > 255 turns ---
uint256 highTotalTurns = 257; // Odd number > 255
uint8 winningScoreTarget = 255; // Max value for uint8
// Create and join game
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(highTotalTurns, TIMEOUT);
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// --- State Manipulation: Set scoreA=255, currentTurn=lastTurn, state=Committed ---
// Get the base storage slot for this game instance
// Slot 0 is the 'games' mapping itself.
bytes32 gameBaseSlot = keccak256(abi.encode(gameId, uint256(0)));
// Calculate slots for specific fields based on struct layout
// currentTurn is at offset 8 (0-indexed) from the base slot
bytes32 currentTurnSlot = bytes32(uint256(gameBaseSlot) + 8);
// scoreA is packed into slot 10 with moveA, moveB, scoreB, state
// Packed data (moveA, moveB, scoreA, scoreB, state) starts at offset 11
bytes32 packedDataSlot = bytes32(uint256(gameBaseSlot) + 11);
// Construct the packed data value we want to store:
// moveA @ bits 0-7
// moveB @ bits 8-15
// scoreA @ bits 16-23
// scoreB @ bits 24-31
// state @ bits 32-39
uint256 packedValue = uint256(uint8(RockPaperScissors.Move.None)) // moveA = 0
| (uint256(uint8(RockPaperScissors.Move.None)) << 8) // moveB = 0
| (uint256(winningScoreTarget) << 16) // scoreA = 255
| (uint256(0) << 24) // scoreB = 0
| (uint256(uint8(RockPaperScissors.GameState.Committed)) << 32); // state = 1
bytes32 packedDataBytes = bytes32(packedValue);
// Use vm.store to write the manipulated values directly to storage
vm.store(address(game), currentTurnSlot, bytes32(highTotalTurns)); // Set to the last turn
vm.store(address(game), packedDataSlot, packedDataBytes); // Set moves=None, scoreA=255, scoreB=0, state=Committed
// --- Verification of State Manipulation (Optional but recommended) ---
(
,,,,,,, // Skip first 7 fields
, // totalTurns
uint256 currentTurnCheck,
, bytes32 commitBCheck, // commitA, commitB
RockPaperScissors.Move moveACheck, RockPaperScissors.Move moveBCheck,
uint8 scoreACheck, uint8 scoreBCheck,
RockPaperScissors.GameState stateCheck
) = game.games(gameId);
assertEq(currentTurnCheck, highTotalTurns, "Manual currentTurn set failed");
assertEq(scoreACheck, winningScoreTarget, "Manual scoreA set failed");
assertEq(uint256(stateCheck), uint256(RockPaperScissors.GameState.Committed), "Manual state set failed");
assertEq(uint256(moveACheck), 0, "Manual moveA set failed");
assertEq(uint256(moveBCheck), 0, "Manual moveB set failed");
// --- Action: Play the final turn where Player A wins again ---
// Commit moves for the final turn (A=Paper, B=Rock -> A wins)
bytes32 saltA = keccak256(abi.encodePacked("saltA_overflow", gameId));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
bytes32 saltB = keccak256(abi.encodePacked("saltB_overflow", gameId));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB));
vm.prank(playerB);
game.commitMove(gameId, commitB);
// Reveal move A
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Paper), saltA);
// --- Assertion: Expect Revert on Player B's reveal ---
// Player B's reveal triggers _determineWinner, which attempts scoreA++ (255 -> 256)
// This should cause an arithmetic overflow revert in Solidity >=0.8.0
vm.expectRevert(); // Default revert for arithmetic overflow
vm.prank(playerB);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltB);
}

Impact

  • Denial of Service (DoS): Games configured with a number of turns potentially allowing scores > 255 cannot be completed if a player reaches 256 wins. The game becomes stuck, unable to process the final turns or determine a winner.

  • Locked Funds: When the transaction reverts during the score increment (likely during the revealMove call of the second player), the game state remains unresolved (Committed or partially revealed). The payout functions (_finishGame, _handleTie) are never reached, resulting in the permanent locking of staked ETH or Tokens within the contract for that game.

  • Game Design Limitation: The use of uint8 imposes an implicit, potentially unexpected limit on the maximum practical number of turns or winning rounds per player, regardless of the uint256 type used for totalTurns.

Tools Used

  • Manual Code Review

  • Foundry/Forge (for Test Execution, PoC verification, and vm.store)

Recommendations

To mitigate this vulnerability, the data type for scores should be adjusted, or input validation should be added to prevent configurations that could lead to overflow.

  1. Increase Score Variable Size (Recommended): Change the data type of scoreA and scoreB in the Game struct from uint8 to a larger integer type, such as uint16.

    struct Game {
    // ...
    - uint8 scoreA; // Score for player A
    - uint8 scoreB; // Score for player B
    + uint16 scoreA; // Score for player A (Max 65535)
    + uint16 scoreB; // Score for player B (Max 65535)
    // ...
    }

    A uint16 allows for up to 65,535 wins per player, which is highly unlikely to be exceeded in any practical Rock Paper Scissors game scenario, effectively eliminating the overflow risk while still being reasonably gas-efficient.

  2. Add Input Validation (Alternative): Alternatively, enforce a maximum limit on _totalTurns during game creation that ensures scores cannot exceed 255.

    function createGameWithEth(uint256 _totalTurns, uint256 _timeoutInterval) external payable returns (uint256) {
    require(msg.value >= minBet, "Bet amount too small");
    require(_totalTurns > 0, "Must have at least one turn");
    require(_totalTurns % 2 == 1, "Total turns must be odd");
    + require(_totalTurns <= 509, "Total turns exceeds score capacity"); // Max turns for uint8 score (255 * 2 + 1 = 511, but odd required)
    require(_timeoutInterval >= 5 minutes, "Timeout must be at least 5 minutes");
    // ...
    }
    // Similar check in createGameWithToken

    This approach is less flexible but prevents the overflow condition from ever being reachable.

Using uint16 for scores (Recommendation 1) is generally preferred as it provides ample headroom for game length without imposing potentially arbitrary limits during game creation.

Updates

Appeal created

m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
m3dython Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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