MyCut

AI First Flight #8
Beginner FriendlyFoundry
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

ingle point of failure due to centralized owner control enables complete protocol takeover

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.

// Root cause in the codebase with @> marks to highlight the relevant section
// ContestManager.sol - Lines 7-9
contract ContestManager is Ownable {
address[] public contests;
mapping(address => uint256) public contestToTotalRewards;
error ContestManager__InsufficientFunds();
constructor() Ownable(msg.sender) {} // @audit Single owner, no multi-sig
// @audit All critical functions restricted to single owner
function createContest(...) public onlyOwner { }
function fundContest(...) public onlyOwner { }
function closeContest(...) public onlyOwner { }
}

Risk

Likelihood:

  • Reason1

  • Private key compromise occurs through:

    • Phishing attacks (very common)

    • Malware/keyloggers

  • Reason 2

Impact:

  • Impact 1

  • Attack vectors with compromised owner key:

    Steal all pending funds

  • Impact 2

Proof of Concept

// Simulate owner key compromise
function testOwnerKeyCompromiseFullProtocolTakeover() public {
// Setup: 3 active contests with real funds
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;
}
// Owner creates 3 contests, 15,000 USDC total locked
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();
// OWNER KEY COMPROMISED
address attacker = address(0xBAD);
// Attacker gets owner private key via phishing
// Transfers ownership or just uses the key
vm.startPrank(owner); // Attacker now has owner key
// Attack 1: Create malicious contest
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
);
// Attack 2: Fund malicious contest (drains owner's USDC)
usdc.approve(address(contestManager), 50000);
contestManager.fundContest(3); // 50,000 USDC to attacker's pot
vm.stopPrank();
// Attack 3: Attacker claims immediately
vm.prank(attacker);
Pot(maliciousPot).claimCut();
// Attacker successfully stole 50,000 USDC
assertEq(usdc.balanceOf(attacker), 50000);
// Attack 4: Close all legitimate contests prematurely
vm.warp(block.timestamp + 91 days);
vm.startPrank(owner); // Attacker still has key
for (uint i = 0; i < 3; i++) {
contestManager.closeContest(contests[i]);
// Manager cut goes to attacker (still has owner key)
}
vm.stopPrank();
// Result:
// - Legitimate players lost access to their funds
// - Attacker stole 50,000 + manager cuts
// - Protocol completely compromised
}

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
}
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!