Root + Impact
Description
When a signer is revoked via revokeSigningRole, their confirmations on pending transactions remain counted, but they cannot revoke them because revokeConfirmation requires onlyRole(SIGNING_ROLE). Execution only checks the confirmation count, not whether confirmers are still active signers.
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
}
Comparison with revocation:
Risk
Likelihood:
Impact:
Proof of Concept
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract PoC_RevokedSignerConfirmations is Test {
MultiSigTimelock public multiSig;
address owner = address(this);
address signerA = makeAddr("signerA");
address signerB = makeAddr("signerB");
address signerC = makeAddr("signerC");
address recipient = makeAddr("recipient");
function setUp() public {
multiSig = new MultiSigTimelock();
multiSig.grantSigningRole(signerA);
multiSig.grantSigningRole(signerB);
multiSig.grantSigningRole(signerC);
}
function test_RevokedSignerConfirmationRemainsValid() public {
uint256 txnId = multiSig.proposeTransaction(recipient, 1 ether, "");
vm.prank(signerA);
multiSig.confirmTransaction(txnId);
vm.prank(signerB);
multiSig.confirmTransaction(txnId);
multiSig.revokeSigningRole(signerA);
vm.prank(signerA);
vm.expectRevert();
multiSig.revokeConfirmation(txnId);
vm.prank(signerC);
multiSig.confirmTransaction(txnId);
multiSig.confirmTransaction(txnId);
vm.deal(address(multiSig), 1 ether);
vm.warp(block.timestamp + 1 days);
multiSig.executeTransaction(txnId);
assertTrue(multiSig.getTransaction(txnId).executed);
}
function test_ExecuteDoesNotCheckActiveSigners() public {
uint256 txnId = multiSig.proposeTransaction(recipient, 1 ether, "");
vm.prank(signerA);
multiSig.confirmTransaction(txnId);
vm.prank(signerB);
multiSig.confirmTransaction(txnId);
vm.prank(signerC);
multiSig.confirmTransaction(txnId);
multiSig.revokeSigningRole(signerA);
multiSig.revokeSigningRole(signerB);
multiSig.revokeSigningRole(signerC);
assertFalse(multiSig.hasRole(multiSig.getSigningRole(), signerA));
vm.deal(address(multiSig), 1 ether);
vm.warp(block.timestamp + 1 days);
multiSig.executeTransaction(txnId);
assertTrue(multiSig.getTransaction(txnId).executed);
}
}
Steps to Reproduce:
Deploy MultiSigTimelock contract and grant SIGNING_ROLE to multiple signers (e.g., signerA, signerB, signerC)
Propose a transaction with a timelock (e.g., 1 ETH → 1 day delay)
Have signerA and signerB confirm the transaction
Revoke signerA's role via revokeSigningRole(signerA) (e.g., due to compromised key)
Verify signerA cannot revoke their confirmation (reverts due to missing SIGNING_ROLE)
Have signerC confirm the transaction (now has 3 confirmations, including revoked signerA)
Wait for timelock period to expire
Execute the transaction - it succeeds despite signerA being revoked
Recommended Mitigation
Add validation in _executeTransaction to ensure all confirmations come from active signers:
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// CHECKS
// 1. Check if enough confirmations
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
+
+ // 2. Verify all confirmations are from active signers
+ uint256 activeConfirmations = 0;
+ for (uint256 i = 0; i < s_signerCount; i++) {
+ if (s_signatures[txnId][s_signers[i]] && s_isSigner[s_signers[i]]) {
+ activeConfirmations++;
+ }
+ }
+ if (activeConfirmations < REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, activeConfirmations);
+ }
- // 2. Check if timelock period has passed
+ // 3. Check if timelock period has passed
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
if (txn.value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
// EFFECTS
- // 3. Mark as executed BEFORE the external call (prevent reentrancy)
+ // 4. Mark as executed BEFORE the external call (prevent reentrancy)
txn.executed = true;
// INTERACTIONS
- // 4. Execute the transaction
+ // 5. Execute the transaction
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
- // 5. Emit eventt
+ // 6. Emit event
emit TransactionExecuted(txnId, txn.to, txn.value);
}