Rock Paper Scissors

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

Zero/Trivial Commit Hash Validation Vulnerability in RockPaperScissors

Summary

The RockPaperScissors contract lacks validation for commit hashes in the commitMove function, allowing players to submit zero or trivial values that can be easily predicted, breaking the security of the commit-reveal scheme.

Vulnerability Details

The vulnerability exists in the commitMove function where there is no validation of the _commitHash parameter:

function commitMove(uint256 _gameId, bytes32 _commitHash) external {
Game storage game = games[_gameId];
if (msg.sender == game.playerA) {
require(game.commitA == bytes32(0), "Already committed");
game.commitA = _commitHash; // No validation!
} else {
require(game.commitB == bytes32(0), "Already committed");
game.commitB = _commitHash; // No validation!
}
// ...
}

Here's a PoC demonstrating the exploit:

// 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 constant BET_AMOUNT = 0.1 ether;
uint256 public constant TIMEOUT = 10 minutes;
uint256 public constant TOTAL_TURNS = 3;
function setUp() public {
game = new RockPaperScissors();
attacker = makeAddr("attacker");
victim = makeAddr("victim");
vm.deal(attacker, 1 ether);
vm.deal(victim, 1 ether);
}
function testExploitTrivialCommitHash() public {
// 1. Victim creates game
vm.prank(victim);
uint256 gameId = game.createGameWithEth{value: BET_AMOUNT}(
TOTAL_TURNS,
TIMEOUT
);
// 2. Attacker joins
vm.prank(attacker);
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// First turn: Victim plays Rock, Attacker plays Paper
// 3. Victim commits Rock
bytes32 victimSalt = keccak256(abi.encodePacked("victim salt"));
bytes32 victimCommit = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Rock), victimSalt)
);
vm.prank(victim);
game.commitMove(gameId, victimCommit);
// 4. Attacker commits Paper with trivial salt
bytes32 attackerSalt = bytes32(uint256(1));
bytes32 attackerCommit = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Paper), attackerSalt)
);
vm.prank(attacker);
game.commitMove(gameId, attackerCommit);
// 5. Victim reveals Rock
vm.prank(victim);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), victimSalt);
// 6. Attacker reveals Paper
vm.prank(attacker);
game.revealMove(
gameId,
uint8(RockPaperScissors.Move.Paper),
attackerSalt
);
// Verify first turn results
(
,
,
,
,
,
,
,
,
,
,
,
RockPaperScissors.Move moveA,
RockPaperScissors.Move moveB,
uint8 scoreA,
uint8 scoreB,
) = game.games(gameId);
assertEq(uint8(moveA), uint8(RockPaperScissors.Move.None)); // Moves are reset after turn
assertEq(uint8(moveB), uint8(RockPaperScissors.Move.None));
assertEq(scoreA, 0); // Victim lost
assertEq(scoreB, 1); // Attacker won
// Second turn: Victim plays Scissors, Attacker plays Rock
victimCommit = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), victimSalt)
);
vm.prank(victim);
game.commitMove(gameId, victimCommit);
attackerCommit = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Rock), attackerSalt)
);
vm.prank(attacker);
game.commitMove(gameId, attackerCommit);
vm.prank(victim);
game.revealMove(
gameId,
uint8(RockPaperScissors.Move.Scissors),
victimSalt
);
vm.prank(attacker);
game.revealMove(
gameId,
uint8(RockPaperScissors.Move.Rock),
attackerSalt
);
// Final turn: Victim plays Paper, Attacker plays Scissors
victimCommit = keccak256(
abi.encodePacked(uint8(RockPaperScissors.Move.Paper), victimSalt)
);
vm.prank(victim);
game.commitMove(gameId, victimCommit);
attackerCommit = keccak256(
abi.encodePacked(
uint8(RockPaperScissors.Move.Scissors),
attackerSalt
)
);
vm.prank(attacker);
game.commitMove(gameId, attackerCommit);
vm.prank(victim);
game.revealMove(
gameId,
uint8(RockPaperScissors.Move.Paper),
victimSalt
);
uint256 attackerBalanceBefore = attacker.balance;
vm.prank(attacker);
game.revealMove(
gameId,
uint8(RockPaperScissors.Move.Scissors),
attackerSalt
);
// Verify final game state
(, , , , , , , , , , , , , scoreA, scoreB, ) = game.games(gameId);
assertEq(scoreA, 0); // Victim lost all turns
assertEq(scoreB, 3); // Attacker won all turns
// Calculate attacker's profit
uint256 totalPot = BET_AMOUNT * 2;
uint256 fee = (totalPot * 10) / 100; // 10% protocol fee
uint256 expectedPrize = totalPot - fee;
assertEq(attacker.balance - attackerBalanceBefore, expectedPrize);
}
}

Impact

  • Attackers can predict opponent's moves and consistently win games

  • Direct loss of user funds through rigged games

  • Breaks the fundamental fairness mechanism of the game

  • Affects all games played on the platform

  • Severity: HIGH - due to direct fund loss and broken core mechanics

Tools Used

  • Foundry for testing and PoC development

  • Manual code review

Recommendations

Implement minimum entropy requirements:

function isValidCommit(bytes32 _commitHash) internal pure returns (bool) {
// Require at least 200 bits of entropy
uint256 setBits = 0;
uint256 value = uint256(_commitHash);
while (value != 0) {
setBits += value & 1;
value >>= 1;
}
return setBits >= 200;
}

Add commit hash validation:

function commitMove(uint256 _gameId, bytes32 _commitHash) external {
require(_commitHash != bytes32(0), "Invalid commit: zero hash");
require(_commitHash > bytes32(uint256(100)), "Invalid commit: trivial hash");
// ... rest of function
}
Updates

Appeal created

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

Lack of Salt Uniqueness Enforcement

The contract does not enforce salt uniqueness

Support

FAQs

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