Rock Paper Scissors

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

[M-1] Obscure Recovery Path for Abandoned Games

Summary

The RockPaperScissors contract has a poorly documented recovery mechanism for games abandoned by one player. While timeoutJoin cannot be used when a second player has joined but becomes inactive, a functional but obscure recovery path exists through the timeoutReveal function.

Vulnerability Details

The timeoutJoin function is designed to handle timeouts when no player joins a game, but it explicitly prevents recovery when a second player has already joined:

function timeoutJoin(uint256 _gameId) external {
Game storage game = games[_gameId];
require(game.state == GameState.Created, "Game must be in created state");
require(block.timestamp > game.joinDeadline, "Join deadline not reached yet");
require(game.playerB == address(0), "Someone has already joined the game");
_cancelGame(_gameId);
}

However, a recovery path exists through the timeoutReveal function through this sequence:

  1. Player A creates a game

  2. Player B joins the game but becomes inactive

  3. Player A commits a move, changing the game state to GameState.Committed

  4. Since Player B never commits, game.revealDeadline remains at its default value of 0

  5. Player A calls timeoutReveal() which:

    • Passes the check game.state == GameState.Committed

    • Passes the check block.timestamp > game.revealDeadline (since revealDeadline is 0)

    • Enters the condition (!playerARevealed && !playerBRevealed) since nobody revealed

    • Calls _cancelGame(_gameId) which refunds both players

This recovery path works but is unintuitive and undocumented, creating a usability issue for players.

Impact

Low - Funds are not permanently at risk as there is a recovery mechanism available. However, the mechanism is unintuitive and undocumented, which may lead to confusion and unnecessary stress for users who believe their funds are locked.

Likelihood

High - It's highly probable that players will occasionally abandon games due to lost keys, disinterest, or other reasons. Given the popularity of blockchain games, player abandonment is an expected regular occurrence.

According to the CodeHawks risk matrix, this vulnerability has a Medium severity rating (Low impact + High likelihood = Medium).

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
contract RockPaperScissorsVulnTest is Test {
RockPaperScissors public game;
WinningToken public token;
address public admin;
address public playerA;
address public playerB;
address public playerC; // Malicious player
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 1; // Single turn for simplicity
function setUp() public {
// Set up addresses
admin = address(this);
playerA = makeAddr("playerA");
playerB = makeAddr("playerB");
playerC = makeAddr("playerC");
// Fund the players
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
vm.deal(playerC, 10 ether);
// Deploy contracts
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
// Mint tokens for testing token games
vm.prank(address(game));
token.mint(playerA, 10);
vm.prank(address(game));
token.mint(playerB, 10);
vm.prank(address(game));
token.mint(playerC, 10);
}
function testPlayerCommitsAndTimeoutRecovery() public {
// 1. Player A creates a game
uint256 playerAInitialBalance = playerA.balance;
uint256 playerBInitialBalance = playerB.balance;
vm.prank(playerA);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// 2. Player B joins the game
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// 3. Player A commits a move
bytes32 saltA = keccak256(abi.encodePacked("salt for player A"));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// 4. Player B never commits (simulating inactivity)
// 5. Player A calls timeoutReveal to recover funds
vm.prank(playerA);
game.timeoutReveal(gameId);
// 6. Verify funds were recovered
(,,,,,,,,,,,,,,,RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Cancelled));
assertEq(playerA.balance, playerAInitialBalance);
assertEq(playerB.balance, playerBInitialBalance);
}
}

Tools Used

Manual code review and custom unit tests.

Recommendations

Improve the contract's usability and predictability by:

  1. Adding a dedicated, clearly-named function for abandoned games:

function timeoutAbandonedGame(uint256 _gameId) external {
Game storage game = games[_gameId];
require(game.playerB != address(0), "No player has joined yet");
// Handle abandoned game in Created state
if (game.state == GameState.Created && block.timestamp > game.joinDeadline + 1 hours) {
_cancelGame(_gameId);
return;
}
// Handle abandoned game in Committed state with only one commit
if (game.state == GameState.Committed) {
bool onlyOneCommit = (game.commitA != bytes32(0) && game.commitB == bytes32(0)) ||
(game.commitA == bytes32(0) && game.commitB != bytes32(0));
if (onlyOneCommit && block.timestamp > game.joinDeadline + 1 hours) {
_cancelGame(_gameId);
return;
}
}
revert("Game not eligible for inactive timeout");
}
  1. Clearly document all recovery mechanisms in comments and user documentation, including the existing path through timeoutReveal.

  2. Add helper functions that make the current state of a game more transparent to users:

function isGameAbandoned(uint256 _gameId) external view returns (bool) {
Game storage game = games[_gameId];
// Logic to determine if a game appears abandoned
// Return true if recovery actions are available
}
Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
m3dython Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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