MultiSig Timelock

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

No Transaction Cancellation Mechanism

Author Revealed upon completion

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.

// @> No cancelTransaction function exists in the contract
// Only functions available:
// - proposeTransaction (creates new)
// - confirmTransaction (adds confirmation)
// - revokeConfirmation (removes own confirmation only)
// - executeTransaction (executes)
// @> Missing: cancelTransaction to fully remove a pending transaction

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 {
// Setup 3 signers
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
vm.deal(address(multiSigTimelock), 100 ether);
// Propose transaction to attacker address
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(
address(0xBAD), // Malicious recipient
100 ether,
""
);
// 3 signers confirm (maybe social engineered)
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
// Signers realize it's malicious - but they can only revoke their own confirmations
// Even if all revoke, transaction still exists and can be re-confirmed
// NO WAY TO CANCEL THE TRANSACTION
// It will remain pending forever unless executed
// After timelock, anyone with SIGNING_ROLE can execute
vm.warp(block.timestamp + 7 days + 1);
// If even one signer is compromised later, they can re-confirm and execute
}

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

Support

FAQs

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

Give us feedback!