Rock Paper Scissors

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

Missing Commit Phase Timeout Mechanism Allows Game Blocking

Summary

The RockPaperScissors contract implements timeout mechanisms for both the join phase and reveal phase, but critically lacks a timeout mechanism for the commit phase. This fundamental oversight allows malicious players to permanently block games by simply refusing to commit their moves, resulting in locked funds and unresolvable game states.

Vulnerability Details

While the contract properly handles timeouts for other game phases with functions like timeoutJoin and timeoutReveal, there is no equivalent mechanism to handle situations where players refuse to commit their moves:

// The contract has these timeout functions:
function timeoutJoin(uint256 _gameId) external { /* ... */ }
function timeoutReveal(uint256 _gameId) external { /* ... */ }
// But there is no timeoutCommit function

This vulnerability is especially problematic in multi-round games, as a player who realizes they are at a disadvantage can exploit this to prevent the game from reaching its natural conclusion.

Impact

This vulnerability results in several critical issues:

  • Fund Lockup: Players' bets can be permanently locked in the contract with no mechanism to recover them

  • Denial of Service: Games can be intentionally stalled by uncooperative players

  • Griefing Attacks: Players who realize they are likely to lose can hold games hostage

  • Economic Damage: The inability to complete games or recover funds discourages participation

Tools Used

Manual review

Proof of Concept

Consider this scenario in a 5-round game:

  1. Player A has won 1 round

  2. Player B has won 2 rounds

  3. Player A realizes that even winning the remaining rounds would only result in a tie at best

  4. Player A simply refuses to submit a commit for the next round

  5. The game is permanently stuck in the commit phase

  6. No timeout function can be called

  7. Both players' bets remain locked in the contract indefinitely

Unlike the reveal phase, where timeouts can be triggered to resolve the game, the commit phase provides no resolution mechanism.

Recommendations

Implement a comprehensive commit phase timeout system:

  1. Add a commit deadline to the Game struct:

    struct Game {
    // ... existing fields
    uint256 commitDeadline; // Add this field
    }
  2. Set the commit deadline when appropriate:

    // Set initial commit deadline when game is created
    game.commitDeadline = block.timestamp + COMMIT_TIMEOUT;
    // Reset commit deadline for new rounds
    if (game.currentTurn < game.totalTurns) {
    // ... existing resets
    game.commitDeadline = block.timestamp + COMMIT_TIMEOUT;
    }
  3. Implement a timeout function for the commit phase:

    function timeoutCommit(uint256 _gameId) external {
    Game storage game = games[_gameId];
    require(game.state == GameState.Committed, "Game not in commit phase");
    require(block.timestamp > game.commitDeadline, "Commit phase not timed out yet");
    // Check if either player has committed
    bool playerACommitted = game.commitA != bytes32(0);
    bool playerBCommitted = game.commitB != bytes32(0);
    if (playerACommitted && !playerBCommitted) {
    // Player A committed but B didn't - A wins by default
    _finishGame(_gameId, game.playerA);
    } else if (!playerACommitted && playerBCommitted) {
    // Player B committed but A didn't - B wins by default
    _finishGame(_gameId, game.playerB);
    } else {
    // Neither player committed - cancel game and refund
    _cancelGame(_gameId);
    }
    }
  4. Add a helper function to check if commit timeout is possible:

    function canTimeoutCommit(uint256 _gameId) external view returns (bool canTimeout, address winnerIfTimeout) {
    Game storage game = games[_gameId];
    if (game.state != GameState.Committed || block.timestamp <= game.commitDeadline) {
    return (false, address(0));
    }
    bool playerACommitted = game.commitA != bytes32(0);
    bool playerBCommitted = game.commitB != bytes32(0);
    if (playerACommitted && !playerBCommitted) {
    return (true, game.playerA);
    } else if (!playerACommitted && playerBCommitted) {
    return (true, game.playerB);
    } else if (!playerACommitted && !playerBCommitted) {
    return (true, address(0)); // Game would be cancelled
    }
    return (false, address(0));
    }
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.