MultiSig Timelock

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

Timelock bypass through transaction splitting

Author Revealed upon completion

Description

  • A value‑based timelock should prevent large outflows from being executed quickly, ensuring signers have a long review window before high‑value transfers leave the wallet.

  • The contract computes delay per transaction using only txn.value. There is no aggregation across pending/executed transactions within a period. An attacker or careless operator can split a ≥100 ETH payment into many smaller proposals (e.g., 100×1 ETH). Each smaller transaction receives a short delay (1 day), allowing rapid serial execution that collectively bypasses the intended 7‑day window for ≥100 ETH outflows.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// @> Delay is computed per-single transaction only
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);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
emit TransactionExecuted(txnId, txn.to, txn.value);
}
function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
// @> Tiered solely by the single tx 'value'; no cumulative/rolling window logic
// <1 ETH => 0; 1-10 => 1 day; 10-100 => 2 days; >=100 => 7 days
...
}

Risk

Likelihood: Medium

  • Treasury operations often pay large sums; splitting into many 1–10 ETH transfers is trivial and common in scripting or manual batching.

  • This will occur whenever payers prefer faster settlement and can restructure payments into smaller chunks that fit shorter delay tiers.

Impact: High

  • Policy bypass: The effective 7‑day review window for ≥100 ETH is defeated; the same total can exit in ~1 day through multiple smaller transfers.

  • Governance erosion & monitoring complexity: Observers may think the timelock protects large outflows; in reality, cumulative outflows can drain quickly, undermining risk controls.

Proof of Concept

  • Copy the code below to MultiSigTimeLockTest.t.sol.

  • Run command forge test --mt testTimelockBypassThroughSplitting -vv.

function testTimelockBypassThroughSplitting() public {
// Fund the wallet with 100 ETH
address RECIPIENT = makeAddr("recipient");
vm.deal(address(multiSigTimelock), 100 ether);
console2.log("MultiSigTimelock initial balance:", address(multiSigTimelock).balance);
console2.log("Recipient initial balance:", RECIPIENT.balance);
// Prepare signers (owner is signer #1 by default)
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
// --- Single large transaction: 100 ETH (should require 7 days) ---
uint256 bigTxnId = multiSigTimelock.proposeTransaction(RECIPIENT, 100 ether, hex"");
multiSigTimelock.confirmTransaction(bigTxnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(bigTxnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(bigTxnId);
// Warp 1 day only
vm.warp(block.timestamp + 1 days);
// Attempt to execute the big transaction: should revert (needs 7-day delay)
vm.expectRevert(); // TimelockHasNotExpired
multiSigTimelock.executeTransaction(bigTxnId);
// --- Split strategy: 100 transactions of 1 ETH (each has 1 day delay) ---
uint256[] memory smallIds = new uint256[](100);
for (uint256 i = 0; i < 100; i++) {
smallIds[i] = multiSigTimelock.proposeTransaction(RECIPIENT, 1 ether, hex"");
multiSigTimelock.confirmTransaction(smallIds[i]);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(smallIds[i]);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(smallIds[i]);
}
// Wait 1 day (enough for the 1–10 ETH tier)
vm.warp(block.timestamp + 1 days);
// Execute all small transfers immediately after 1 day, draining the same 100 ETH
for (uint256 i = 0; i < 100; i++) {
multiSigTimelock.executeTransaction(smallIds[i]);
}
// Assert: recipient now has 100 ETH, achieved in ~1 day via splitting
assertEq(RECIPIENT.balance, 100 ether);
console2.log("Recipient final balance after splitting strategy:", RECIPIENT.balance);
console2.log("MultiSigTimelock final balance after splitting strategy:", address(multiSigTimelock).balance);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/unit/MultiSigTimelockTest.t.sol:MultiSigTimeLockTest
[PASS] testTimelockBypassThroughSplitting() (gas: 22144974)
Logs:
MultiSigTimelock initial balance: 100000000000000000000
Recipient initial balance: 0
Recipient final balance after splitting strategy: 100000000000000000000
MultiSigTimelock final balance after splitting strategy: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 19.72ms (15.89ms CPU time)
Ran 1 test suite in 21.03ms (19.72ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Aggregate‑aware timelock (global rolling window)

  • Scale the timelock based on cumulative queued value in a rolling period (e.g., 7 days), not just single‑tx value.

+ // Track total 'queued but not executed' ETH over a rolling window
+ struct Bucket { uint256 amount; uint256 windowStart; }
+ Bucket private s_globalOutflow; // simple single-bucket example
+ // Helper to compute rolling sum (naive single-window illustration)
+ function _pendingOutflowWithinWindow(uint256 nowTs, uint256 window) internal view returns (uint256) {
+ if (nowTs > s_globalOutflow.windowStart + window) return 0;
+ return s_globalOutflow.amount;
+ }
function _proposeTransaction(address to, uint256 value, bytes memory data) internal returns (uint256) {
uint256 transactionId = s_transactionCount;
s_transactions[transactionId] = Transaction({
to: to,
value: value,
data: data,
confirmations: 0,
proposedAt: block.timestamp,
executed: false
});
s_transactionCount++;
+ // Update global outflow bucket (reset window if expired)
+ uint256 W = 7 days;
+ if (block.timestamp > s_globalOutflow.windowStart + W) {
+ s_globalOutflow.windowStart = block.timestamp;
+ s_globalOutflow.amount = 0;
+ }
+ s_globalOutflow.amount += value;
emit TransactionProposed(transactionId, to, value);
return transactionId;
}
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
- uint256 requiredDelay = _getTimelockDelay(txn.value);
+ // Base delay from single tx value
+ uint256 requiredDelay = _getTimelockDelay(txn.value);
+ // Escalate delay if cumulative queued outflow in window crosses thresholds
+ uint256 W = 7 days;
+ uint256 cumulative = _pendingOutflowWithinWindow(block.timestamp, W);
+ if (cumulative >= 100 ether && requiredDelay < SEVEN_DAYS_TIME_DELAY) {
+ requiredDelay = SEVEN_DAYS_TIME_DELAY;
+ } else if (cumulative >= 10 ether && requiredDelay < TWO_DAYS_TIME_DELAY) {
+ requiredDelay = TWO_DAYS_TIME_DELAY;
+ } else if (cumulative >= 1 ether && requiredDelay < ONE_DAY_TIME_DELAY) {
+ requiredDelay = ONE_DAY_TIME_DELAY;
+ }
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
if (txn.value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
+ // On success, reduce queued outflow
+ if (block.timestamp <= s_globalOutflow.windowStart + W && s_globalOutflow.amount >= txn.value) {
+ s_globalOutflow.amount -= txn.value;
+ }
emit TransactionExecuted(txnId, txn.to, txn.value);
}

Support

FAQs

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

Give us feedback!