Root + Impact
Description
-
Describe the normal behavior in one or more sentences
-
The normal behavior should be: critical protocol operations require multi-signature approval or timelocked governance to prevent single-key compromise.
-
Explain the specific issue or problem in one or more sentences
-
The entire ContestManager protocol relies on a single owner address with unrestricted control over all contests and funds.
contract ContestManager is Ownable {
address[] public contests;
mapping(address => uint256) public contestToTotalRewards;
error ContestManager__InsufficientFunds();
constructor() Ownable(msg.sender) {}
function createContest(...) public onlyOwner { }
function fundContest(...) public onlyOwner { }
function closeContest(...) public onlyOwner { }
}
Risk
Likelihood:
Impact:
Proof of Concept
function testOwnerKeyCompromiseFullProtocolTakeover() public {
address[] memory players = new address[](5);
uint256[] memory rewards = new uint256[](5);
for (uint i = 0; i < 5; i++) {
players[i] = address(uint160(i + 100));
rewards[i] = 1000;
}
vm.startPrank(owner);
for (uint i = 0; i < 3; i++) {
address pot = contestManager.createContest(players, rewards, usdc, 5000);
usdc.approve(address(contestManager), 5000);
contestManager.fundContest(i);
}
vm.stopPrank();
address attacker = address(0xBAD);
vm.startPrank(owner);
address[] memory attackerPlayers = new address[](1);
attackerPlayers[0] = attacker;
uint256[] memory attackerRewards = new uint256[](1);
attackerRewards[0] = 50000;
address maliciousPot = contestManager.createContest(
attackerPlayers,
attackerRewards,
usdc,
50000
);
usdc.approve(address(contestManager), 50000);
contestManager.fundContest(3);
vm.stopPrank();
vm.prank(attacker);
Pot(maliciousPot).claimCut();
assertEq(usdc.balanceOf(attacker), 50000);
vm.warp(block.timestamp + 91 days);
vm.startPrank(owner);
for (uint i = 0; i < 3; i++) {
contestManager.closeContest(contests[i]);
}
vm.stopPrank();
}
Recommended Mitigation
- remove this code
+ add this code// ContestManager.sol
- import {Ownable} from "lib/openzeppelin-contracts/contracts/access/Ownable.sol";
+ import {AccessControl} from "lib/openzeppelin-contracts/contracts/access/AccessControl.sol";
- contract ContestManager is Ownable {
+ contract ContestManager is AccessControl {
+ bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
+ bytes32 public constant CONTEST_MANAGER_ROLE = keccak256("CONTEST_MANAGER_ROLE");
+
+ uint256 public constant MIN_SIGNERS = 2;
+
- constructor() Ownable(msg.sender) {}
+ constructor(address[] memory admins) {
+ require(admins.length >= MIN_SIGNERS, "Need at least 2 admins");
+
+ for (uint i = 0; i < admins.length; i++) {
+ _grantRole(ADMIN_ROLE, admins[i]);
+ _grantRole(CONTEST_MANAGER_ROLE, admins[i]);
+ }
+
+ _setRoleAdmin(CONTEST_MANAGER_ROLE, ADMIN_ROLE);
+ }
- function createContest(...) public onlyOwner {
+ function createContest(...) public onlyRole(CONTEST_MANAGER_ROLE) {
// ... existing logic
}
- function fundContest(...) public onlyOwner {
+ function fundContest(...) public onlyRole(CONTEST_MANAGER_ROLE) {
// ... existing logic
}
- function closeContest(...) public onlyOwner {
+ function closeContest(...) public onlyRole(ADMIN_ROLE) {
// ... existing logic
}
}