MultiSig Timelock

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

Ghost Signers in `s_signers` list lead to permanent Multi-Sig Wallet Lock

Author Revealed upon completion

Ghost Signers in s_signers list lead to permanent Multi-Sig Wallet Lock

Description

The MultiSigTimelock contract inherits from OpenZeppelin's AccessControl contract for permission management. AccessControl includes a public function renounceRole which allows an signer to unilaterally relinquish its assigned role SIGNING_ROLE without authorization from the contract Owner.

However, MultiSigTimelock implements internal tracking of active signers through a custom s_signerCount counter and an s_isSigner mapping. Since the contract fails to override the inherited renounceRole function, any signer who renounces their role will trigger a state mismatch: the role is removed at the AccessControl level, but the internal s_signerCount and s_isSigner states remain unchanged.

function renounceRole(bytes32 role, address callerConfirmation) public virtual {
if (callerConfirmation != _msgSender()) {
revert AccessControlBadConfirmation();
}
_revokeRole(role, callerConfirmation);
}

Risk

Likelihood: High

Any signer can call function renounceRole.

Impact:
Renouncing roles prevents the quorum from being met and freezes transaction execution, while the stale s_signerCount blocks the Owner from adding new signers, effectively allowing the authorized signer count to drop to zero and bypassing the "at least one signer" safety rule.

Proof of Concept

  • Three signers unilaterally renounce their roles, proving they can exit without Owner approval.

  • The test confirms "Ghost Signers" exist because the registry still lists addresses that no longer hold signing roles.

  • The expectRevert proves the Owner is blocked from adding new signers because the stale counter falsely indicates the wallet is full.

place the following code in MultiSigTimelockTest.t.sol:

function testAllSignerCanCallRenounceRole() public grantSigningRoles {
bytes32 role = multiSigTimelock.getSigningRole();
// 3 signer renounceRole
vm.prank(SIGNER_TWO);
multiSigTimelock.renounceRole(role, SIGNER_TWO);
vm.prank(SIGNER_THREE);
multiSigTimelock.renounceRole(role, SIGNER_THREE);
vm.prank(SIGNER_FOUR);
multiSigTimelock.renounceRole(role, SIGNER_FOUR);
uint256 signerCount = multiSigTimelock.getSignerCount();
console2.log(signerCount);
address[5] memory signers = multiSigTimelock.getSigners();
// there are signers in Signer list which do not have role
for (uint256 i = 0; i < signerCount; i++) {
assert(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(),signers[i]));
}
// owner cannot add new Signer
vm.expectRevert();
vm.prank(OWNER);
multiSigTimelock.grantSigningRole(SIGNER_TWO);
}

Recommended Mitigation:Override the function renounceRole to disable it for all users.

+ function renounceRole(bytes32 role, address account) public override {
+ revert;
+ }

Support

FAQs

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

Give us feedback!