MultiSig Timelock

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

Timelock Can Be Bypassed By Delaying Confirmations

Author Revealed upon completion

Description

  • The timelock mechanism is designed to enforce delay periods for high-value transactions (1-7 days based on ETH amount) to prevent rushed or compromised transfers.

  • However, the timelock is calculated from proposedAt (when the transaction is proposed), not from when the quorum of 3 confirmations is reached. This allows the timelock to be completely bypassed by simply delaying the confirmation process.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// ...
// @> Timelock calculated from proposedAt, not from when quorum was reached
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay; // @> ROOT CAUSE
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
// ...
}

Risk

Likelihood:

  • Attackers or colluding signers can intentionally delay confirmations past the timelock period

  • Natural delays in coordination among signers also trigger this bypass unintentionally

  • Only requires waiting - no complex attack needed

Impact:

  • 100 ETH+ transactions (meant to have 7-day delay) can be executed immediately after last confirmation

  • The core security feature of the protocol (timelock protection) is rendered useless

  • High-value transactions have no meaningful protection against rushed execution

  • Compromised signers can coordinate to drain funds instantly

Proof of Concept

function testTimelockBypassViaDelayedConfirmation() public {
// Setup signers
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
// Fund contract with 100 ETH (should require 7-day timelock)
vm.deal(address(multiSigTimelock), 100 ether);
// Owner proposes 100 ETH transaction
vm.prank(OWNER);
uint256 txnId = multiSigTimelock.proposeTransaction(
address(0xBAD),
100 ether,
""
);
// ATTACK: Wait 7 days BEFORE confirming
vm.warp(block.timestamp + 7 days + 1);
// Now confirm quickly
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
// Execute IMMEDIATELY after confirmations - no additional wait needed!
// The 7-day timelock has already "passed" even though
// confirmations just happened
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txnId); // SUCCESS - 100 ETH drained instantly
assertEq(address(0xBAD).balance, 100 ether);
}

Recommended Mitigation

Track when quorum is reached and calculate timelock from that point:

struct Transaction {
address to;
uint256 value;
bytes data;
uint256 confirmations;
uint256 proposedAt;
+ uint256 quorumReachedAt; // Track when 3 confirmations achieved
bool executed;
}
function _confirmTransaction(uint256 txnId) internal {
if (s_signatures[txnId][msg.sender]) {
revert MultiSigTimeLock__UserAlreadySigned();
}
s_signatures[txnId][msg.sender] = true;
s_transactions[txnId].confirmations++;
+ // Record when quorum is first reached
+ if (s_transactions[txnId].confirmations == REQUIRED_CONFIRMATIONS) {
+ s_transactions[txnId].quorumReachedAt = block.timestamp;
+ }
emit TransactionConfirmed(txnId, msg.sender);
}
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;
+ uint256 executionTime = txn.quorumReachedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
// ...
}

Support

FAQs

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

Give us feedback!