MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Owner Centralization Risk - Single Point of Failure

  • The contract inherits from Ownable which centralizes critical functionality to a single owner address. The owner has exclusive control over proposing transactions and managing signers.

  • If the owner's private key is compromised or lost, the entire multisig becomes inoperable. The owner can unilaterally add/remove signers and propose transactions without any checks, creating a centralized point of failure that defeats the purpose of a multisig wallet.

function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
@> onlyOwner // Only owner can propose - centralization risk
returns (uint256)
{
return _proposeTransaction(to, value, data);
}
function grantSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
@> // Owner has absolute power to add signers
if (s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsAlreadyASigner();
}
// ... rest of function
}
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
@> // Owner has absolute power to remove signers
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
// ... rest of function
}

Risk

Likelihood:

  • The owner's private key could be compromised through phishing, malware, or other attacks at any time

  • Loss of the owner's private key renders the contract permanently inoperable since no other address can propose transactions or manage signers

Impact:

  • Complete loss of control over the multisig wallet if owner key is compromised

  • All funds locked permanently if owner key is lost (no recovery mechanism)

  • Owner can collude with 2 other signers to drain funds without proper oversight

  • Defeats the decentralization principle of a multisig wallet

Proof of Concept

function testOwnerCanDrainFundsWithCollusion() public {
// Owner adds two colluding signers
multiSig.grantSigningRole(attacker1);
multiSig.grantSigningRole(attacker2);
// Owner proposes malicious transaction
uint256 txId = multiSig.proposeTransaction(attackerAddress, 200 ether, "");
// Only needs 3 confirmations (owner + 2 colluding signers)
multiSig.confirmTransaction(txId);
vm.prank(attacker1);
multiSig.confirmTransaction(txId);
vm.prank(attacker2);
multiSig.confirmTransaction(txId);
// Execute and drain all funds
multiSig.executeTransaction(txId);
// All 200 ETH stolen by colluding parties
}

Recommended Mitigation

- contract MultiSigTimelock is Ownable, AccessControl, ReentrancyGuard {
+ contract MultiSigTimelock is AccessControl, ReentrancyGuard {
+ bytes32 private constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
+ uint256 private constant MIN_ADMINS = 2;
+
+ error MultiSigTimelock__InsufficientAdmins();
function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
- onlyOwner
+ onlyRole(SIGNING_ROLE) // Any signer can propose
returns (uint256)
{
return _proposeTransaction(to, value, data);
}
function grantSigningRole(address _account)
external
nonReentrant
- onlyOwner
+ onlyRole(ADMIN_ROLE) // Require admin role instead of single owner
noneZeroAddress(_account)
{
// ... rest of function
}
function revokeSigningRole(address _account)
external
nonReentrant
- onlyOwner
+ onlyRole(ADMIN_ROLE)
noneZeroAddress(_account)
{
+ uint256 adminCount = getRoleMemberCount(ADMIN_ROLE);
+ if (hasRole(ADMIN_ROLE, _account) && adminCount <= MIN_ADMINS) {
+ revert MultiSigTimelock__InsufficientAdmins();
+ }
// ... rest of function
}
constructor() {
+ _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
+ _grantRole(ADMIN_ROLE, msg.sender);
s_signers[0] = msg.sender;
s_isSigner[msg.sender] = true;
s_signerCount = 1;
_grantRole(SIGNING_ROLE, msg.sender);
}
Updates

Lead Judging Commences

kelechikizito Lead Judge 4 days ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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

Give us feedback!