Summary
The RockPaperScissors smart contract implements a commit-reveal scheme to ensure fairness in a two-player game. However, the current implementation is vulnerable to commit-reveal griefing, where one player can stall the game after seeing the opponent's revealed move. This results in a denial of service for the other player and halts game progression.
Vulnerability Details
The vulnerability lies in the fact that one player can choose not to reveal their committed move after the opponent has revealed theirs, effectively stalling the game. This griefing strategy is possible because there are no incentives or penalties around the reveal phase.
Exploit Scenario:
-
Player A commits Rock
-
Player B commits Paper (which beats Rock)
-
Player B reveals first - revealing Paper
-
Player A refuses to reveal, stalling the game
Proof of Concept
The following Foundry test demonstrates how to stalling the game:
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
contract CommitRevealTimingAbuse is Test {
RockPaperScissors public game;
event GameCreated(
uint256 indexed gameId,
address indexed creator,
uint256 bet,
uint256 totalTurns
);
event PlayerJoined(uint256 indexed gameId, address indexed player);
event MoveCommitted(
uint256 indexed gameId,
address indexed player,
uint256 currentTurn
);
event MoveRevealed(
uint256 indexed gameId,
address indexed player,
RockPaperScissors.Move move,
uint256 currentTurn
);
event TurnCompleted(
uint256 indexed gameId,
address winner,
uint256 currentTurn
);
event GameFinished(uint256 indexed gameId, address winner, uint256 prize);
event GameCancelled(uint256 indexed gameId);
event JoinTimeoutUpdated(uint256 oldTimeout, uint256 newTimeout);
event FeeCollected(uint256 gameId, uint256 feeAmount);
event FeeWithdrawn(address indexed admin, uint256 amount);
WinningToken public token;
address public admin;
address public playerA;
address public playerB;
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 3;
uint256 public gameId;
function setUp() public {
admin = address(this);
playerA = makeAddr("playerA");
playerB = makeAddr("playerB");
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
vm.prank(address(game));
token.mint(playerA, 10);
vm.prank(address(game));
token.mint(playerB, 10);
}
function createAndJoinGame() internal returns (uint256) {
vm.prank(playerA);
uint256 id = game.createGameWithEth{value: BET_AMOUNT}(
TOTAL_TURNS,
TIMEOUT
);
vm.prank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(id);
return id;
}
function testCommitRevealAbuse() public {
gameId = createAndJoinGame();
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);
bytes32 saltB = keccak256(abi.encodePacked("salt for player B"));
bytes32 commitB = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB)
);
vm.prank(playerB);
game.commitMove(gameId, commitB);
vm.prank(playerB);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Paper), saltB);
(
,
,
,
,
,
,
,
,
uint256 currentTurn,
,
,
RockPaperScissors.Move moveA,
RockPaperScissors.Move moveB,
uint8 scoreA,
uint8 scoreB,
RockPaperScissors.GameState state
) = game.games(gameId);
assertEq(uint256(moveB), uint8(RockPaperScissors.Move.Paper));
assertEq(uint256(moveA), uint8(RockPaperScissors.Move.None));
assertEq(currentTurn, 1);
assertEq(scoreA, 0);
assertEq(scoreB, 0);
assertEq(
uint256(state),
uint256(RockPaperScissors.GameState.Committed)
);
}
}
Impact
Tools Used
Recommendations
Add commitDeadline:
// Game structure
struct Game {
address playerA; // Creator of the game
address playerB; // Second player to join
uint256 bet; // Amount of ETH bet
uint256 timeoutInterval; // Time allowed for reveal phase
uint256 revealDeadline; // Deadline for revealing moves
+ uint256 commitDeadline; // Deadline for committing moves
uint256 creationTime; // When the game was created
uint256 joinDeadline; // Deadline for someone to join the game
uint256 totalTurns; // Total number of turns in the game
uint256 currentTurn; // Current turn number
bytes32 commitA; // Hashed move from player A
bytes32 commitB; // Hashed move from player B
Move moveA; // Revealed move from player A
Move moveB; // Revealed move from player B
uint8 scoreA; // Score for player A
uint8 scoreB; // Score for player B
GameState state; // Current state of the game
}
To fix the vulnerability, modify the revealMove
function to strictly enforce time limits and implement mechanisms like automatic forfeit or score assignment if one player fails to reveal in time. Below is the secured version of revealMove
:
@@
/**
* @notice Reveal committed move
* @param _gameId ID of the game
* @param _move Player's move (1=Rock, 2=Paper, 3=Scissors)
* @param _salt Random salt used in the commit phase
*/
- function revealMove(uint256 _gameId, uint8 _move, bytes32 _salt) external {
+ // Reveal a move (hashing the move and salt)
+ function revealMove(uint256 _gameId, uint8 _move, bytes32 _salt) 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 timed out"
- );
- require(_move >= 1 && _move <= 3, "Invalid move");
+ // Ensure that the caller is one of the players in the game
+ require(
+ msg.sender == game.playerA || msg.sender == game.playerB,
+ "Not a player in this game"
+ );
+
+ // Ensure the game is in the 'Committed' state
+ require(game.state == GameState.Committed, "Game not in reveal phase");
+
+ // Ensure the reveal phase has not timed out
+ require(
+ block.timestamp <= game.revealDeadline,
+ "Reveal phase timed out"
+ );
+
+ // Ensure the move is valid
+ require(_move >= 1 && _move <= 3, "Invalid move");
Move move = Move(_move);
bytes32 commit = keccak256(abi.encodePacked(move, _salt));
- if (msg.sender == game.playerA) {
- require(commit == game.commitA, "Hash doesn't match commitment");
- require(game.moveA == Move.None, "Move already revealed");
- game.moveA = move;
- } else {
- require(commit == game.commitB, "Hash doesn't match commitment");
- require(game.moveB == Move.None, "Move already revealed");
- game.moveB = move;
- }
+ // Check if the player is Player A
+ if (msg.sender == game.playerA) {
+ require(
+ commit == game.commitA,
+ "Hash doesn't match commitment for player A"
+ );
+ require(
+ game.moveA == Move.None,
+ "Move already revealed for player A"
+ );
+ game.moveA = move;
+ } else {
+ // Check if the player is Player B
+ require(
+ commit == game.commitB,
+ "Hash doesn't match commitment for player B"
+ );
+ require(
+ game.moveB == Move.None,
+ "Move already revealed for player B"
+ );
+ game.moveB = move;
+ }
- emit MoveRevealed(_gameId, msg.sender, move, game.currentTurn);
+ // Emit event for move revelation
+ emit MoveRevealed(_gameId, msg.sender, move, game.currentTurn);
- // If both players have revealed, determine the winner for this turn
+ // If both players have revealed their moves, determine the winner
if (game.moveA != Move.None && game.moveB != Move.None) {
_determineWinner(_gameId);
}
}