Rock Paper Scissors

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

Improper Timeout Handling in Multi-Turn Games

Summary

The Rock Paper Scissors contract contains a critical flaw in its timeout mechanism during multi-turn games. Once the first turn is completed, Player A can unfairly claim victory by utilizing the timeoutReveal function in subsequent turns, even if Player B is winning the match. This occurs because the contract does not reset or update the reveal deadline appropriately when transitioning between turns and doesn't verify that both players have committed their moves in the current turn before allowing timeout claims.

Vulnerability Details

In the current implementation, when a game transitions from one turn to the next, the contract fails to properly reset or adjust the revealDeadline. This creates a situation where, in turns after the first one, the following exploit is possible:

  1. Player A commits and reveals their move for the current turn

  2. Player A waits for the revealDeadline to pass without requiring Player B to commit their move

  3. Player A calls timeoutReveal, allowing them to claim victory unfairly

The core issue is in the timeoutReveal function, which checks only the following conditions:

require(msg.sender == game.playerA || msg.sender == game.playerB, "Not a player in this game");
require(game.state == GameState.Committed, "Game not in reveal phase");
require(block.timestamp > game.revealDeadline, "Reveal deadline not passed");

Then it determines the winner using logic that doesn't account for the current state of the game in a multi-turn scenario:

if (msg.sender == game.playerA && playerARevealed && !playerBRevealed) {
// Player A wins by timeout
_finishGame(_gameId, game.playerA);
}

This means that a timeout in any turn leads to declaring an overall winner of the entire game, regardless of the score at that point.

This was successfully demonstrated in the testMultiTurnTimeout() test by modifying it to remove Player B's commit in the second turn:

// Second turn - player A commits and reveals, player B doesn't commit
bytes32 saltA = keccak256(abi.encodePacked("salt for turn 2"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// Player B's commit is removed to demonstrate the vulnerability
// vm.prank(playerB);
// game.commitMove(gameId, commitB);
// Only player A reveals
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
// Fast forward past reveal deadline
vm.warp(block.timestamp + TIMEOUT + 1);
// Player A claims timeout win - and unfairly wins the whole game
vm.prank(playerA);
game.timeoutReveal(gameId);

The test confirms that Player A successfully claims the entire pot, even though Player B was leading with a score of 1-0 after the first turn.

Impact

This vulnerability has a high impact as it:

  1. Allows a player to unfairly win the entire game's pot regardless of the current score

  2. Creates a disincentive for fair play, as the winning strategy becomes exploiting the timeout rather than actual gameplay

  3. Undermines the game's integrity by allowing a player to win without their opponent having a chance to commit a move

The financial impact is significant as the winning player takes the entire pot (minus fees), resulting in the full loss of funds for the opponent.

Tools Used

  • Manual code review

  • Foundry tests for PoC

Recommendations

To address this vulnerability, several changes should be implemented:

Distinct Game States: Implement separate states for each phase of the game (waiting for commits, waiting for reveals) rather than using a single "Committed" state for multiple phases.

Reset Timeout Values: Properly reset the reveal deadline when transitioning to a new turn:

// When advancing to the next turn
game.revealDeadline = block.timestamp + game.timeoutInterval;

Require Both Players to Commit: Add a check in the revealMove and timeoutReveal functions to ensure both players have committed before allowing reveals or timeouts in each turn:

require(game.commitA != bytes32(0) && game.commitB != bytes32(0), "Both players must commit first");

Add Turn Validation: Include the current turn number in commit and reveal operations to ensure players can't reuse commitments or reveals from previous turns.

Updates

Appeal created

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

Invalid TimeoutReveal Logic Error

timeoutReveal function incorrectly allows execution and game cancellation even when only one player has committed

Support

FAQs

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