Rock Paper Scissors

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

Critical Access Control Flaw in `setAdmin()` Enables Full Fee Drain in `RockPaperScissors.sol`

๐Ÿชฒ Finding Title

Critical Access Control Flaw in setAdmin() Enables Full Fee Drain


๐Ÿงฉ Summary

The RockPaperScissors.sol contract defines a single adminAddress role which is responsible for managing key protocol operations, including setting a new admin and withdrawing accumulated protocol fees. However, the access control mechanism used by setAdmin() lacks sufficient governance protections, allowing a privileged user to transfer admin rights without oversight.

If an attacker is able to become or impersonate the current admin (through a logic bug or deployment misconfiguration), they can assign themselves as the new admin and immediately drain all collected fees using withdrawFees().


๐Ÿ” Finding Description

The vulnerability originates from the self-governed nature of setAdmin():

function setAdmin(address _newAdmin) external {
require(msg.sender == adminAddress, "Only admin can set new admin");
require(_newAdmin != address(0), "Admin cannot be zero address");
โ€‹
adminAddress = _newAdmin;
}
  • The only requirement is that msg.sender == adminAddress.

  • No ownership check, multi-sig approval, or time lock is enforced.

  • If an attacker becomes admin (via initial misconfiguration or another bug), they can call setAdmin() to take over the protocol.

Once admin, the attacker can invoke withdrawFees():

function withdrawFees(uint256 _amount) external {
require(msg.sender == adminAddress, "Only admin can withdraw fees");
โ€‹
uint256 amountToWithdraw = _amount == 0 ? accumulatedFees : _amount;
require(amountToWithdraw <= accumulatedFees, "Insufficient fee balance");
โ€‹
accumulatedFees -= amountToWithdraw;
โ€‹
(bool success,) = adminAddress.call{value: amountToWithdraw}("");
require(success, "Fee withdrawal failed");
โ€‹
emit FeeWithdrawn(adminAddress, amountToWithdraw);
}

This function allows the current admin to withdraw all protocol fees. Together, these two functions create a critical privilege escalation vector.


๐Ÿ’ฅ Impact

  • Unauthorized admin takeover: Any actor who obtains admin rights gains full control over the protocol.

  • Complete fee theft: Accumulated ETH in the contract can be drained without detection or restriction.

  • No recovery: Once admin rights are transferred, there is no built-in way to revert it.

  • No governance: No owner, DAO, or multi-sig involvement is enforced, leaving the protocol permanently exposed.


๐Ÿ“Œ Vulnerable Code (Combined)

function setAdmin(address _newAdmin) external {
require(msg.sender == adminAddress, "Only admin can set new admin");
require(_newAdmin != address(0), "Admin cannot be zero address");
โ€‹
adminAddress = _newAdmin; // โ— No governance, no external verification
}
โ€‹
function withdrawFees(uint256 _amount) external {
require(msg.sender == adminAddress, "Only admin can withdraw fees");
โ€‹
uint256 amountToWithdraw = _amount == 0 ? accumulatedFees : _amount;
require(amountToWithdraw <= accumulatedFees, "Insufficient fee balance");
โ€‹
accumulatedFees -= amountToWithdraw;
โ€‹
(bool success,) = adminAddress.call{value: amountToWithdraw}(""); // โ— All ETH sent to current admin
require(success, "Fee withdrawal failed");
โ€‹
emit FeeWithdrawn(adminAddress, amountToWithdraw);
}

๐Ÿงช Proof of Concept (PoC)

See: AccessControlPoC_Final.t.sol

This PoC simulates a privileged attacker changing the admin address to their own, then draining all collected fees:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
โ€‹
import "lib/openzeppelin-contracts/lib/forge-std/src/Test.sol";
import "../src/RockPaperScissors.sol";
โ€‹
contract AccessControlPoCTest is Test {
RockPaperScissors public game;
โ€‹
address public originalAdmin;
address public attacker;
โ€‹
function setUp() public {
originalAdmin = makeAddr("OriginalAdmin");
attacker = makeAddr("Attacker");
โ€‹
vm.deal(originalAdmin, 10 ether);
vm.deal(attacker, 10 ether);
โ€‹
// Deploy the game contract from the original admin
vm.prank(originalAdmin);
game = new RockPaperScissors();
}
โ€‹
function testAdminTakeoverAndWithdrawFees() public {
// โœ… Ensure the contract has real ETH balance
vm.deal(address(game), 1 ether);
โ€‹
// โœ… Set correct slot for accumulatedFees
vm.store(
address(game),
bytes32(uint256(4)), // confirmed slot from forge inspect
bytes32(uint256(1 ether))
);
โ€‹
// โœ… attacker becomes admin
vm.prank(originalAdmin);
game.setAdmin(attacker);
โ€‹
// โœ… attacker withdraws the full fee balance
vm.prank(attacker);
game.withdrawFees(0);
โ€‹
assertEq(address(attacker).balance, 11 ether, "Attacker successfully stole protocol fees");
}
}

The test passes, confirming that all funds were successfully sent to the attacker's address.


โœ… Recommendation

To prevent this vulnerability:

  1. ๐Ÿ” Replace adminAddress-based control with an Ownable or AccessControl system.

  2. ๐Ÿ” Make setAdmin() callable only by owner() or through a governance contract.

  3. ๐Ÿ•’ Optionally introduce a delay or timelock mechanism before role transitions take effect.

  4. ๐Ÿ” Add an event for admin transfer with old and new address to improve transparency.

Recommended pattern:

function setAdmin(address _newAdmin) external onlyOwner {
require(_newAdmin != address(0), "Zero address");
emit AdminChanged(adminAddress, _newAdmin);
adminAddress = _newAdmin;
}

๐Ÿ“Œ Severity Justification

Metric Assessment
Impact High โ€“ full fee theft
Likelihood Medium โ€“ depends on privilege
Exploitability Easy if admin compromised
Recoverability None โ€“ irreversible state

Severity: Critical


โœ… Conclusion

The lack of robust access control in setAdmin() allows any actor with admin privileges to permanently seize control over the protocol and withdraw all funds. This type of unchecked privilege escalation is a critical risk and must be addressed using established governance and role management practices.

Updates

Appeal created

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

Owner is Trusted

m3dython Lead Judge 4 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.