Rock Paper Scissors

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

(HIGH) Re-entrancy on payout

Summary

  1. The functions responsible for distributing ETH prizes (_finishGame, _handleTie) and refunding bets (_cancelGame) perform external calls (.call{value: ...}) before all effects of the function are completed (specifically,

  2. before minting WinningToken in _finishGame and _cancelGame, and between player payouts in _handleTie).

  3. If the recipient is a malicious contract, it can re-enter the RockPaperScissors contract during the ETH transfer, potentially interacting with other functions or games before the initial function fully completes, violating the Checks-Effects-Interactions pattern.

  4. When the game sends ETH to a winner or refunds players, if that player is a malicious smart contract, it can run its own code before the game contract finishes its current task (like minting a winner token). This malicious code could call back into the game contract and try to perform actions it shouldn't be able to, like interfering with another game it's playing, because the first operation isn't fully complete yet.

  5. Technical Root Cause: The vulnerability stems from violating the Checks-Effects-Interactions (CEI) pattern. The external interaction (ETH transfer via .call) happens before all internal state changes (effects, like winningToken.mint) are finalized. The low-level .call function forwards all available gas by default, allowing the recipient contract ample gas to execute potentially complex logic and re-enter the calling contract. While the game.state is updated before the call, preventing simple replay attacks on the same game, reentrancy allows interaction with other parts of the contract state during the vulnerable window.

Vulnerability Details

The issue exists because external calls are made before the state is fully settled according to the function's logic.

Location 1: src/RockPaperScissors.sol, Function _finishGame
https://github.com/CodeHawks-Contests/2025-04-rock-paper-scissors/blob/main/src/RockPaperScissors.sol#L472-L505

function _finishGame(uint256 _gameId, address _winner) internal {
Game storage game = games[_gameId];
game.state = GameState.Finished; // State changed
uint256 prize = 0;
// Handle ETH prizes
if (game.bet > 0) {
// Calculate total pot and fee
uint256 totalPot = game.bet * 2;
uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100;
prize = totalPot - fee;
// Accumulate fees for admin to withdraw later
accumulatedFees += fee;
emit FeeCollected(_gameId, fee);
// Send prize to winner
(bool success,) = _winner.call{value: prize}(""); // <-- EXTERNAL CALL (Interaction)
require(success, "Transfer failed");
}
// Handle token prizes - winner gets both tokens
if (game.bet == 0) {
// Mint a winning token
winningToken.mint(_winner, 2); // <-- Effect after Interaction
} else {
// Mint a winning token for ETH games too
winningToken.mint(_winner, 1); // <-- Effect after Interaction
}
emit GameFinished(_gameId, _winner, prize);
}

Location 2: src/RockPaperScissors.sol, Function: _handleTie
https://github.com/CodeHawks-Contests/2025-04-rock-paper-scissors/blob/main/src/RockPaperScissors.sol#L511-L541

function _handleTie(uint256 _gameId) internal {
Game storage game = games[_gameId];
game.state = GameState.Finished; // State changed
// Return ETH bets to both players, minus protocol fee
if (game.bet > 0) {
// Calculate protocol fee (10% of total pot)
uint256 totalPot = game.bet * 2;
uint256 fee = (totalPot * PROTOCOL_FEE_PERCENT) / 100;
uint256 refundPerPlayer = (totalPot - fee) / 2;
// Accumulate fees for admin
accumulatedFees += fee;
emit FeeCollected(_gameId, fee);
// Refund both players
(bool successA,) = game.playerA.call{value: refundPerPlayer}(""); // <-- EXTERNAL CALL 1 (Interaction)
(bool successB,) = game.playerB.call{value: refundPerPlayer}(""); // <-- EXTERNAL CALL 2 (Interaction)
require(successA && successB, "Transfer failed");
}
// Return tokens for token games
if (game.bet == 0) {
winningToken.mint(game.playerA, 1); // <-- Effect potentially after Interaction
winningToken.mint(game.playerB, 1); // <-- Effect potentially after Interaction
}
// Since in a tie scenario, the total prize is split equally
emit GameFinished(_gameId, address(0), 0);
}

Location 3: src/RockPaperScissors.sol, Function: _cancelGame
https://github.com/CodeHawks-Contests/2025-04-rock-paper-scissors/blob/main/src/RockPaperScissors.sol#L547-L573

function _cancelGame(uint256 _gameId) internal {
Game storage game = games[_gameId];
game.state = GameState.Cancelled; // State changed
// Refund ETH to players
if (game.bet > 0) {
(bool successA,) = game.playerA.call{value: game.bet}(""); // <-- EXTERNAL CALL 1 (Interaction)
require(successA, "Transfer to player A failed");
if (game.playerB != address(0)) {
(bool successB,) = game.playerB.call{value: game.bet}(""); // <-- EXTERNAL CALL 2 (Interaction)
require(successB, "Transfer to player B failed");
}
}
// Return tokens for token games
if (game.bet == 0) {
if (game.playerA != address(0)) {
winningToken.mint(game.playerA, 1); // <-- Effect potentially after Interaction
}
if (game.playerB != address(0)) {
winningToken.mint(game.playerB, 1); // <-- Effect potentially after Interaction
}
}
emit GameCancelled(_gameId);
}

Impact

  1. Interfere with other games: If the attacker is participating in multiple games, they could re-enter during the payout of Game 1 and call functions like commitMove or revealMove for Game 2, potentially exploiting timing or state inconsistencies.

  2. Exploit future vulnerabilities: While no direct fund theft from the current game payout seems possible due to the prior state change, the reentrancy opens the door to exploiting other potential bugs (e.g., if an admin function had a flaw reachable during reentrancy).

  3. Cause unexpected state changes: The reentrancy could lead to transient states where contract invariants are violated before the initial function call completes, although the specific impact depends heavily on the reentrant call target. The immediate impact isn't direct theft of the current prize pool, but the potential for state corruption or exploitation of other interactions elevates the severity. Business impact includes potential loss of user funds in edge cases, damage to game integrity, and reputational harm.

Exploitation Methodology

  1. Attacker deploys a malicious contract (AttackerContract).

  2. Attacker uses AttackerContract address to create or join a RockPaperScissors ETH game (Game ID X). Let's assume AttackerContract is Player A.

  3. Attacker plays the game normally against Player B (can be another address controlled by the attacker or a legitimate player).

  4. Attacker ensures AttackerContract wins Game X.

  5. When Player B makes the final reveal triggering _finishGame, the RockPaperScissors contract calls _finishGame(X, address(AttackerContract)).

  6. Inside _finishGame, the line _winner.call{value: prize}("") executes, sending ETH to AttackerContract.
    AttackerContract's receive() or fallback function is triggered.

  7. Inside the receive() function, AttackerContract immediately calls back into the RockPaperScissors contract (re-entrancy). For example, it could call commitMove for another game (Game ID Y) it is participating in.

  8. The commitMove call executes before the winningToken.mint(_winner, 1) line in the original _finishGame call for Game X.

  9. The execution flow returns to _finishGame, which then attempts to mint the token.

Tools Used

  1. Manual Code

  2. AI for repport ehancement

Recommendations

  1. Apply Checks-Effects-Interactions Pattern: Modify _finishGame, _handleTie, and _cancelGame to perform all state changes (Effects), including token minting, before sending ETH (Interaction).

  2. Use Reentrancy Guard: Implement OpenZeppelin's ReentrancyGuard modifier on all non-view/pure external and public functions that modify state or interact with other contracts, especially the payout/refund functions (_finishGame, _handleTie, _cancelGame, timeoutReveal, cancelGame, timeoutJoin). This provides a robust general protection against reentrancy.

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.