Description
-
When a signer's role is revoked via revokeSigningRole(), their previous confirmations on pending transactions are not invalidated.
-
This means a revoked signer's confirmation still counts toward the required 3 confirmations, even though they are no longer authorized to participate in the multisig.
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
s_isSigner[_account] = false;
_revokeRole(SIGNING_ROLE, _account);
}
Risk
Likelihood:
Impact:
-
Revoked signers' votes continue to count, undermining governance integrity
-
A malicious signer can confirm multiple transactions, get revoked, but their confirmations persist
-
Effectively allows "ghost voting" from non-signers
-
Can lead to execution of transactions that current signers don't actually approve
Proof of Concept
function testRevokedSignerConfirmationStillCounts() public {
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
vm.deal(address(multiSigTimelock), 1 ether);
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(address(0x123), 0.5 ether, "");
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
multiSigTimelock.revokeSigningRole(SIGNER_TWO);
assertFalse(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), SIGNER_TWO));
assertEq(multiSigTimelock.getTransaction(txnId).confirmations, 1);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txnId);
}
Recommended Mitigation
Option 1: Invalidate all confirmations when revoking a signer (gas intensive):
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
// ... existing checks ...
+ // Invalidate all pending transaction confirmations from this signer
+ for (uint256 i = 0; i < s_transactionCount; i++) {
+ if (!s_transactions[i].executed && s_signatures[i][_account]) {
+ s_signatures[i][_account] = false;
+ s_transactions[i].confirmations--;
+ }
+ }
// ... existing removal logic ...
}
Option 2: Check signer status at execution time:
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
+ // Count only current valid signers' confirmations
+ uint256 validConfirmations = 0;
+ for (uint256 i = 0; i < s_signerCount; i++) {
+ if (s_signatures[txnId][s_signers[i]]) {
+ validConfirmations++;
+ }
+ }
+
- if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
+ if (validConfirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
// ...
}