MultiSig Timelock

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

Timelock Bypass via Transaction Splitting Allows Instant Large Withdrawals

Author Revealed upon completion

Root + Impact

Scope: MultiSigTimeLock.sol

Description

  • The dynamic timelock mechanism calculates delays based on individual transaction values. Transactions under 1 ETH execute instantly with no delay.

  • This allows 3 colluding signers to bypass the 7-day timelock for large withdrawals by splitting them into multiple sub-1 ETH transactions.

// Root cause in the codebase with @> marks to highlight the relevant section
function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
uint256 sevenDaysTimeDelayAmount = 100 ether;
uint256 twoDaysTimeDelayAmount = 10 ether;
uint256 oneDayTimeDelayAmount = 1 ether;
if (value >= sevenDaysTimeDelayAmount) {
return SEVEN_DAYS_TIME_DELAY; // 7 days
} else if (value >= twoDaysTimeDelayAmount) {
return TWO_DAYS_TIME_DELAY; // 2 days
} else if (value >= oneDayTimeDelayAmount) {
return ONE_DAY_TIME_DELAY; // 1 day
@> } else {
@> return NO_TIME_DELAY; // INSTANT - no cumulative tracking
@> }
}

Risk

Likelihood:

  • Reason 1 // 3 signers collude or are compromised simultaneously

  • Reason 2 // Attacker creates multiple transactions just under the 1 ETH threshold

Impact:

  • Complete bypass of the 7-day security window for 100+ ETH withdrawals

  • Entire contract balance drained instantly with zero delay

  • Timelock security mechanism rendered useless

Proof of Concept

Demonstrates draining 99 ETH instantly when it should require a 7-day delay:

function test_TimelockBypassViaSplitting() public grantSigningRoles {
vm.deal(address(multiSigTimelock), 100 ether);
uint256 smallAmount = 0.99 ether; // Below 1 ETH = no delay
uint256[] memory txnIds = new uint256[](100);
// Create 100 transactions of 0.99 ETH each
for (uint256 i = 0; i < 100; i++) {
txnIds[i] = multiSigTimelock.proposeTransaction(ATTACKER, smallAmount, "");
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnIds[i]);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnIds[i]);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnIds[i]);
}
// Execute ALL instantly - no vm.warp needed
for (uint256 i = 0; i < 100; i++) {
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txnIds[i]);
}
// 99 ETH drained with ZERO delay (should have required 7 days)
assertGe(ATTACKER.balance, 99 ether);
}

Recommended Mitigation

Implement cumulative withdrawal tracking with a rolling time window:

+ uint256 private s_withdrawnInWindow;
+ uint256 private s_windowStart;
+ uint256 private constant WINDOW_DURATION = 24 hours;
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
+ // Reset window if expired
+ if (block.timestamp > s_windowStart + WINDOW_DURATION) {
+ s_windowStart = block.timestamp;
+ s_withdrawnInWindow = 0;
+ }
+
+ // Calculate delay based on cumulative withdrawals
+ uint256 cumulativeValue = s_withdrawnInWindow + txn.value;
+ uint256 requiredDelay = _getTimelockDelay(cumulativeValue);
- uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
// ... rest of checks ...
+ s_withdrawnInWindow += txn.value;
// ... execution logic ...
}

Support

FAQs

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

Give us feedback!