Description
-
Once a transaction is proposed, there is no way to cancel it. Even if all signers agree a transaction is malicious or erroneous, they cannot remove it from the pending queue.
-
If a transaction reaches 3 confirmations (even by mistake or due to a compromised signer), it can be executed once the timelock passes, with no way to prevent it.
Risk
Likelihood:
-
Needed when a proposed transaction turns out to be malicious, erroneous, or outdated
-
Needed when recipient address is discovered to be compromised after proposal
-
Common in real-world multisig operations
Impact:
-
Malicious transactions with quorum cannot be stopped, only delayed by timelock
-
Once 3 signers confirm (even if 2 later realize the mistake), the transaction will execute
-
Creates a "ticking time bomb" scenario for any confirmed malicious transaction
-
Even if confirmations are revoked below quorum, the transaction remains and could be re-confirmed later
Proof of Concept
function testCannotCancelMaliciousTransaction() public {
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
vm.deal(address(multiSigTimelock), 100 ether);
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(
address(0xBAD),
100 ether,
""
);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.warp(block.timestamp + 7 days + 1);
}
Recommended Mitigation
Add a cancel function that allows owner or supermajority to permanently cancel a transaction:
+ error MultiSigTimelock__TransactionAlreadyCancelled(uint256 transactionId);
struct Transaction {
address to;
uint256 value;
bytes data;
uint256 confirmations;
uint256 proposedAt;
bool executed;
+ bool cancelled;
}
+ function cancelTransaction(uint256 txnId)
+ external
+ nonReentrant
+ onlyOwner
+ transactionExists(txnId)
+ notExecuted(txnId)
+ {
+ if (s_transactions[txnId].cancelled) {
+ revert MultiSigTimelock__TransactionAlreadyCancelled(txnId);
+ }
+ s_transactions[txnId].cancelled = true;
+ emit TransactionCancelled(txnId);
+ }
+ modifier notCancelled(uint256 _transactionId) {
+ if (s_transactions[_transactionId].cancelled) {
+ revert MultiSigTimelock__TransactionAlreadyCancelled(_transactionId);
+ }
+ _;
+ }
// Add notCancelled modifier to confirmTransaction and executeTransaction