Rock Paper Scissors

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

Incorrect Game Cancellation via `timeoutReveal` State Reset Bug

Summary

The RockPaperScissors.sol contract contains a state management flaw affecting multi-turn games. After a turn successfully completes (both players reveal on time) and it's not the final turn, the _determineWinner function resets game variables (moveA, moveB, commitA, commitB) and sets the game.state back to Committed in preparation for the next turn. However, it fails to invalidate or clear the revealDeadline associated with the just completed turn. If the revealDeadline for the completed turn passes after this state reset, the timeoutReveal function can be called. It incorrectly interprets the state (Committed, deadline passed, moves == None) as a scenario where neither player revealed for the previous turn, leading it to erroneously call _cancelGame. This allows a player to unfairly trigger a full game cancellation and refund after a turn has legitimately concluded.

Vulnerability Details

  1. Turn Completion (Non-Final): In a multi-turn game, when both players successfully call revealMove for turn N, the _determineWinner function is triggered.

  2. State Reset for Next Turn: Inside _determineWinner, if game.currentTurn < game.totalTurns, the following state changes occur to prepare for turn N+1:

    • game.currentTurn is incremented.

    • game.commitA and game.commitB are reset to bytes32(0).

    • game.moveA and game.moveB are reset to Move.None.

    • game.state is set back to GameState.Committed.

  3. Persistent Deadline: Crucially, the game.revealDeadline which was set when both players committed for turn N is not reset or cleared during this state transition.

  4. Timeout Window: A window opens after the state reset for turn N+1 but before the next turn significantly progresses. During this window, the revealDeadline for turn N may pass (block.timestamp > game.revealDeadline).

  5. timeoutReveal Called: If timeoutReveal(_gameId) is called during this window:

    • The check require(game.state == GameState.Committed, ...) passes because the state was reset for turn N+1.

    • The check require(block.timestamp > game.revealDeadline, ...) passes because the deadline for turn N has expired.

    • The status checks bool playerARevealed = game.moveA != Move.None; and bool playerBRevealed = game.moveB != Move.None; both evaluate to false because the moves were reset to Move.None by _determineWinner.

    • Consequently, the condition else if (!playerARevealed && !playerBRevealed) becomes true.

  6. Incorrect Action: timeoutReveal incorrectly concludes that neither player revealed for turn N and calls the internal function _cancelGame(_gameId).

Proof Of Concept

The provided test case below demonstrates the vulnerability

// ==================== INCORRECT CANCEL VIA TIMEOUTREVEAL TEST ====================
/**
* @notice Tests incorrect game cancellation via timeoutReveal in multi-turn games.
* After a turn completes, _determineWinner resets moves/state, but the old deadline
* can still trigger timeoutReveal, leading to _cancelGame.
*/
function testIncorrectCancelViaTimeoutReveal_MultiTurn() public {
// --- Setup: Create and Join a 3-Turn ETH Game ---
// Ensure TOTAL_TURNS in setup is >= 3 for this test logic
require(TOTAL_TURNS >= 3, "Test requires at least 3 turns");
gameId = createAndJoinGame(); // Uses TOTAL_TURNS from test setup
// --- Action 1: Complete Turn 1 Successfully ---
// Record timestamp before commit to estimate deadline later
uint256 approxCommitTime = block.timestamp;
playTurn(gameId, RockPaperScissors.Move.Paper, RockPaperScissors.Move.Rock); // A wins turn 1
// --- Assertions After Turn 1 Completion (State Reset Verification) ---
(
,,,,,,, // Skip first 7 fields
uint256 totalTurnsCheck,
uint256 currentTurnCheck,
bytes32 commitACheck, bytes32 commitBCheck,
RockPaperScissors.Move moveACheck, RockPaperScissors.Move moveBCheck,
uint8 scoreACheck, uint8 scoreBCheck,
RockPaperScissors.GameState stateCheck
) = game.games(gameId);
assertEq(currentTurnCheck, 2, "Should have advanced to turn 2");
assertEq(uint256(stateCheck), uint256(RockPaperScissors.GameState.Committed), "State should be Committed for turn 2");
assertEq(uint256(moveACheck), uint256(RockPaperScissors.Move.None), "Move A should be reset for turn 2");
assertEq(uint256(moveBCheck), uint256(RockPaperScissors.Move.None), "Move B should be reset for turn 2");
assertEq(scoreACheck, 1, "Score A should be 1 after turn 1 win");
assertEq(scoreBCheck, 0, "Score B should be 0 after turn 1 loss");
// --- Action 2: Warp Time Past Turn 1's Reveal Deadline ---
// The deadline was set during playTurn -> commitMove based on TIMEOUT
// We warp past the time the deadline would have occurred relative to the commit time
vm.warp(approxCommitTime + TIMEOUT + 1); // Go past the deadline for Turn 1
// --- Action 3: Trigger timeoutReveal (using Player A) ---
// This should incorrectly trigger _cancelGame because:
// 1. State is Committed (reset for Turn 2)
// 2. Turn 1's deadline has passed
// 3. Moves are None (reset for Turn 2)
uint256 playerABalanceBefore = playerA.balance;
uint256 playerBBalanceBefore = playerB.balance;
uint256 contractBalanceBefore = address(game).balance; // Should be BET_AMOUNT * 2
uint256 feesBefore = game.accumulatedFees();
vm.expectEmit(true, false, false, true); // Expect GameCancelled event
emit GameCancelled(gameId);
vm.prank(playerA);
game.timeoutReveal(gameId); // This call should succeed but incorrectly trigger cancellation
// --- Assertions: Verify Incorrect Cancellation ---
(,,,,,,,,,,,,,,, RockPaperScissors.GameState finalState) = game.games(gameId);
assertEq(uint256(finalState), uint256(RockPaperScissors.GameState.Cancelled), "BUG: Game state should be Cancelled");
// Verify refunds occurred (as per _cancelGame logic)
assertEq(playerA.balance, playerABalanceBefore + BET_AMOUNT, "BUG: Player A should be refunded full bet");
assertEq(playerB.balance, playerBBalanceBefore + BET_AMOUNT, "BUG: Player B should be refunded full bet");
assertEq(address(game).balance, contractBalanceBefore - (BET_AMOUNT * 2), "BUG: Contract balance should be reduced by both bets");
assertEq(game.accumulatedFees(), feesBefore, "BUG: Fees should not increase on cancel"); // No fee on cancel
}

Impact

  • Unfair Game Termination: Allows a player (potentially one who is losing or anticipates losing) to force the cancellation of a game after a turn has been legitimately won or lost.

  • Denial of Service (Game Progression): Prevents the game from reaching its natural conclusion based on the agreed number of turns and player skill/luck.

  • Griefing: Enables malicious players to disrupt ongoing multi-turn games and force refunds, wasting opponents' time and potentially gas fees.

  • User Trust Erosion: Players expect games to proceed according to the rules; unexpected cancellations after successful turns undermine confidence in the platform's fairness and reliability. (Note: This specific bug leads to refunds, not direct fund theft, but disrupts the game).

Tools Used

  • Manual Code Review

  • Foundry/Forge (for Test Execution and PoC verification)

Recommendations

The root cause is the failure to invalidate the previous turn's revealDeadline when resetting the state for the next turn.

  1. Clear revealDeadline on Turn Progression: Modify the _determineWinner function. Within the if (game.currentTurn < game.totalTurns) block, after resetting other variables, explicitly reset the deadline:

    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.revealDeadline = 0; // Clear the deadline from the previous turn
    game.state = GameState.Committed;
    } else {
    // End game logic...
    }

    This ensures that once a turn is processed, its associated deadline can no longer trigger the timeoutReveal logic.

  2. Add Deadline Check in timeoutReveal (Optional Robustness): As an additional safeguard, consider adding a check in timeoutReveal to ensure the deadline being checked is non-zero. This prevents potential issues if the deadline was somehow already zero.

    function timeoutReveal(uint256 _gameId) external {
    // ... other checks ...
    require(game.state == GameState.Committed, "Game not in reveal phase");
    + require(game.revealDeadline != 0, "Reveal deadline not set"); // Add this check
    require(block.timestamp > game.revealDeadline, "Reveal phase not timed out yet");
    // ... rest of function ...
    }

Implementing Recommendation 1 is essential to fix the vulnerability. Recommendation 2 adds an extra layer of defense.

Updates

Appeal created

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