Rock Paper Scissors

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

Denial of Service on withdrawFees via Malicious Admin Address in RockPaperScissors.sol

Summary

A Denial of Service (DoS) vulnerability exists in the withdrawFees function of the RockPaperScissors contract. A malicious or careless admin can set the adminAddress (via setAdmin) to a contract designed to revert upon receiving Ether (e.g., through its receive/fallback function). When withdrawFees is subsequently called, the low-level .call{value: ...} to the reverting admin address fails. This failure causes the require(success, "Fee withdrawal failed") check within withdrawFees to trigger, reverting the entire withdrawal transaction. Consequently, the accumulated protocol fees become inaccessible and cannot be withdrawn by the currently configured admin address, effectively locking them from that specific admin. While the owner might be able to recover by setting a new admin, this presents a griefing vector for the admin role.

Vulnerability Details

The vulnerability exists within the withdrawFees() function located in src/RockPaperScissors.sol. This function is responsible for transferring accumulated protocol fees to the address stored in the adminAddress state variable.

The function utilizes a low-level external call for the ETH transfer and checks for its success:

// Inside withdrawFees()
(bool success,) = adminAddress.call{value: amountToWithdraw}("");
require(success, "Fee withdrawal failed");

The adminAddress can be updated via the setAdmin() function, typically callable by the current admin or potentially a separate owner role.

The vulnerability arises because an admin can intentionally or accidentally set the adminAddress to point to a contract whose receive() or fallback() function prevents the successful completion of the .call. This occurs if the receiving contract:

  1. Explicitly reverts (e.g., receive() external payable { revert("Blocked"); }).

  2. Consumes all forwarded gas (less likely with default .call behavior but possible).

If the external .call to adminAddress returns success = false due to such a revert or gas issue in the receiver, the require(success, "Fee withdrawal failed") check within withdrawFees() will trigger. This causes the entire withdrawFees transaction to revert.

As a result, if the adminAddress is set to such a non-cooperative contract, the withdrawFees function becomes unusable for withdrawing fees to that specific address. Any attempt to call withdrawFees will fail, preventing the extraction of accumulated fees via this mechanism as long as that address remains the admin.

PoC:

// ==================== NEW GAS GRIEFING POC TEST ====================
function test_POC_GasGriefingWithdrawFees() public {
// Arrange: Generate fees, deploy reverting contract, set as admin
// 1. Generate Fees
console.log("Generating fees...");
gameId = createAndJoinGame_Helper(playerA, playerB);
playTurn_Helper(gameId, playerA, playerB, RockPaperScissors.Move.Rock, RockPaperScissors.Move.Paper);
playTurn_Helper(gameId, playerA, playerB, RockPaperScissors.Move.Scissors, RockPaperScissors.Move.Rock);
playTurn_Helper(gameId, playerA, playerB, RockPaperScissors.Move.Paper, RockPaperScissors.Move.Scissors); // Game ends, fees generated
uint256 feesAvailable = game.accumulatedFees();
assertTrue(feesAvailable > 0, "Setup Failed: No fees generated");
console.log("Fees generated:", feesAvailable);
// 2. Deploy Reverting Receiver Contract
RevertingReceiver revertingAdmin = new RevertingReceiver();
console.log("RevertingReceiver deployed at:", address(revertingAdmin));
// 3. Set Reverting Contract as Admin
address originalAdmin = game.adminAddress(); // Should be address(this)
vm.prank(originalAdmin); // Prank as current admin to change admin
game.setAdmin(address(revertingAdmin));
assertEq(game.adminAddress(), address(revertingAdmin), "Setup Failed: Admin not set to reverting contract");
console.log("Admin set to reverting contract.");
// Act & Assert: Attempt to withdraw fees, expect specific revert
// Prank as the new admin (the reverting contract) to call withdrawFees
vm.prank(address(revertingAdmin));
// Expect withdrawFees to revert because the internal .call() fails
// The require(success, "Fee withdrawal failed") inside withdrawFees should trigger
vm.expectRevert(bytes("Fee withdrawal failed"));
console.log("Attempting withdrawFees (expecting revert)...");
game.withdrawFees(0); // Attempt to withdraw all fees
// If we reach here, expectRevert caught the correct failure
console.log("withdrawFees reverted as expected.");
// Final Assert: Check fees are still locked
assertEq(game.accumulatedFees(), feesAvailable, "Fees were somehow withdrawn!");
assertEq(address(game).balance, feesAvailable, "Contract balance changed unexpectedly!"); // Assuming only fees remain
console.log("SUCCESS: Fees remain locked as withdrawFees is blocked.");
}

PoC Result:

forge test --match-test test_POC_GasGriefingWithdrawFees -vvv
[⠢] Compiling...
[⠰] Compiling 1 files with Solc 0.8.20
[⠒] Solc 0.8.20 finished in 6.10s
Compiler run successful!
Ran 1 test for test/RockPaperScissors.t.sol:RockPaperScissorsTest
[PASS] test_POC_GasGriefingWithdrawFees() (gas: 531674)
Logs:
Generating fees...
Fees generated: 20000000000000000
RevertingReceiver deployed at: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Admin set to reverting contract.
Attempting withdrawFees (expecting revert)...
withdrawFees reverted as expected.
SUCCESS: Fees remain locked as withdrawFees is blocked.
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 5.75ms (1.11ms CPU time)
Ran 1 test suite in 37.32ms (5.75ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

The direct impact of this vulnerability is a Denial of Service (DoS) on the fee withdrawal mechanism. If the adminAddress is set to a contract that reverts upon receiving ETH, the withdrawFees function will consistently fail, preventing the withdrawal of any accumulated protocol fees by that admin.

  • Temporarily Locked Fees: The fees remain locked in the contract and inaccessible via the withdrawFees function as long as the adminAddress points to the non-cooperative contract.

  • Admin Griefing: A malicious admin could exploit this to deliberately prevent fee withdrawals. Alternatively, a careless admin could accidentally achieve the same result by setting an incompatible contract address.

  • Owner Recovery (Mitigation): The overall impact is significantly mitigated if the contract owner (the address that initially deployed the RockPaperScissors contract, assuming ownership wasn't transferred and the owner role retains setAdmin capability) can call setAdmin. The owner could appoint a new, valid adminAddress, thereby restoring the functionality of withdrawFees and allowing the accumulated fees to eventually be recovered. If owner recovery is not possible, the fees would be permanently locked from withdrawal via this function.

  • No Direct User Impact: This vulnerability primarily affects the protocol's ability to collect its fees via the designated admin and does not directly endanger player funds or game integrity.

Tools Used

Manuel code rview

Forge Foundry

Recommendations

The primary issue causing the Denial of Service is that a failure in the external ETH transfer to the adminAddress causes the entire withdrawFees function to revert due to the require(success, ...) check. To mitigate this specific DoS vector:

  1. Consider Removing the require(success) Check in withdrawFees:

    • Modify the withdrawFees function by removing the line:

      (bool success,) = adminAddress.call{value: amountToWithdraw}("");
      - require(success, "Fee withdrawal failed");
      + // Optional: Emit an event if success is false
      + // if (!success) { emit FeeWithdrawalFailed(adminAddress, amountToWithdraw); }
  • Effect: If the .call to adminAddress fails (returns success = false), the withdrawFees function will no longer revert. The state change (accumulatedFees -= amountToWithdraw) will still persist, but the ETH transfer for that specific attempt will simply fail silently (or with an event if added). The fees remain in the contract, available for withdrawal in a subsequent transaction (e.g., after the owner assigns a valid adminAddress).

  • Rationale: This approach places the responsibility for providing a valid, payable address on the admin. If they provide an address that cannot receive ETH, the transfer fails, but it doesn't block the function execution or state updates. This prevents the DoS on the function call itself.

    2.Ensure Robust Owner Recovery for setAdmin:

  • Verify that the setAdmin function includes necessary access control allowing a designated owner role (potentially separate from the operational adminAddress) to forcibly change the adminAddress if the current one becomes unusable or is set maliciously. This provides a crucial recovery path to restore fee withdrawal functionality.

Updates

Appeal created

m3dython Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Owner is Trusted

m3dython Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

Owner is Trusted

Support

FAQs

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