MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
View results
Submission Details
Severity: medium
Valid

Timelock Bypass via Zero-ETH High-Impact Transactions

Root + Impact

Description

  • The multisig is designed to delay execution of risky transactions using a timelock, where higher-value transactions wait longer before execution, giving signers and observers time to react.

  • The timelock duration is determined only by the ETH value (txn.value), completely ignoring the actual effect of the transaction calldata (txn.data). As a result, transactions with zero ETH value but high governance or fund impact execute instantly, bypassing the timelock entirely.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// @> Timelock duration depends only on ETH value
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
}

Risk

Likelihood:

  • Governance and treasury interactions commonly use value = 0 while moving large ERC20 balances or modifying protocol state.

Any signer can execute arbitrary calldata once confirmations are met, making this bypass available during normal protocol usage.

Impact:

  • Timelock protection is rendered ineffective for high-impact actions.

Enables instant execution of governance takeovers, token drains, or admin role changes.

  • Violates signer expectations and governance safety guarantees.

Proof of Concept

  • The following transaction drains ERC20 funds without any timelock delay:

// Step 1: Propose a zero-ETH transaction
uint256 txnId = multisig.proposeTransaction(
address(token),
0, // @> value < 1 ETH → NO_TIME_DELAY
abi.encodeWithSignature(
"transfer(address,uint256)",
attacker,
1_000_000 ether
)
);
// Step 2: Three signers confirm
multisig.confirmTransaction(txnId);
multisig.confirmTransaction(txnId);
multisig.confirmTransaction(txnId);
// Step 3: Execute immediately (no delay)
multisig.executeTransaction(txnId);

Recommended Mitigation

  • Introduce timelock logic that accounts for transaction intent, not just ETH value.

  • Enforce a minimum delay for any external call

Require explicit delay parameters per proposal

  • Classify transactions (ETH transfer vs governance call)

- remove this code
+ add this code
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
- uint256 requiredDelay = _getTimelockDelay(txn.value);
+ uint256 requiredDelay = txn.data.length > 0
+ ? MIN_GOVERNANCE_DELAY
+ : _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
}
Updates

Lead Judging Commences

kelechikizito Lead Judge 4 days ago
Submission Judgement Published
Validated
Assigned finding tags:

No validation of Tx calldata

Support

FAQs

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

Give us feedback!