MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
Submission Details
Impact: medium
Likelihood: low

Owner Can Revoke Own SIGNING_ROLE and Lose Ability to Confirm/Execute

Author Revealed upon completion

Description

  • The owner can call revokeSigningRole() on their own address (as long as there are 2+ signers), removing themselves from the signer list.

  • After this, the owner retains administrative powers (propose transactions, manage signers) but loses the ability to confirm or execute transactions, creating an inconsistent and potentially bricked state.

function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
// @> Only checks if this is the LAST signer, not if it's the OWNER
if (s_signerCount <= 1) {
revert MultiSigTimelock__CannotRevokeLastSigner();
}
// @> Owner can revoke themselves if signerCount > 1
// ... removal logic ...
}

Risk

Likelihood:

  • Owner accidentally calls revokeSigningRole(owner_address)

  • Owner intentionally does this without understanding consequences

  • No protection against self-revocation

Impact:

  • Owner can propose transactions but cannot confirm them

  • Owner cannot execute transactions even if they have quorum

  • Creates operational deadlock if other signers expect owner participation

  • Inconsistent state: admin without signer powers

  • Owner must re-grant themselves the role, which wastes gas and is confusing

Proof of Concept

function testOwnerCanRevokeOwnSigningRole() public {
// Add another signer so owner isn't the last one
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
// Verify owner is a signer
assertTrue(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), OWNER));
// Owner revokes themselves
multiSigTimelock.revokeSigningRole(OWNER);
// Owner no longer has signing role
assertFalse(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), OWNER));
// Owner can still propose (onlyOwner)
vm.deal(address(multiSigTimelock), 1 ether);
uint256 txnId = multiSigTimelock.proposeTransaction(address(0x123), 0.5 ether, "");
// But owner CANNOT confirm their own proposal!
vm.expectRevert(); // AccessControl: missing role
multiSigTimelock.confirmTransaction(txnId);
// Owner also cannot execute even if others confirm
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_FOUR);
multiSigTimelock.confirmTransaction(txnId);
vm.expectRevert(); // AccessControl: missing role
multiSigTimelock.executeTransaction(txnId);
}

Recommended Mitigation

Prevent owner from revoking their own signing role:

function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
if (s_signerCount <= 1) {
revert MultiSigTimelock__CannotRevokeLastSigner();
}
+ // Prevent owner from revoking themselves
+ if (_account == owner()) {
+ revert MultiSigTimelock__CannotRevokeOwner();
+ }
// ... rest of function
}
+ error MultiSigTimelock__CannotRevokeOwner();

Support

FAQs

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

Give us feedback!