MultiSig Timelock

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

19Dec2025_AuditReport2_MultiSigTimelock

Author Revealed upon completion

Root + Impact

Description

  • Timelock Can Be Bypassed Through Value Manipulation

  • The timelock delay is determined solely by the transaction value at proposal time. An attacker who gains control could propose a transaction with a low value (e.g., 0.99 ETH for no timelock), then later modify the transaction data to execute a different action, or chain multiple small transactions to achieve the same effect as one large transaction without the corresponding timelock delay.

// Root cause in the codebase with @> marks to highlight the relevant section
// Instead of one 100 ETH transaction with 7-day delay
// Attacker proposes 101 transactions of 0.99 ETH each with NO delay
for (uint i = 0; i < 101; i++) {
uint256 txId = multiSigTimelock.proposeTransaction(
attackerAddress,
0.99 ether, // Just below 1 ETH threshold
""
);
// Get confirmations and execute immediately (no timelock)
}
// Result: 99.99 ETH transferred without any timelock

Risk

Likelihood:

  • An attacker who gains control could propose a transaction with a low value (e.g., 0.99 ETH for no timelock), then later modify the transaction data to execute a different action, or chain multiple small transactions to achieve the same effect as one large transaction without the corresponding timelock delay.

Impact:

  • An attacker could drain large amounts by splitting transfers into sub-1 ETH transactions with no timelock, or execute time-sensitive malicious actions without delay.

Proof of Concept

function _proposeTransaction(address to, uint256 value, bytes memory data) internal returns (uint256) {
uint256 transactionId = s_transactionCount;
s_transactions[transactionId] = Transaction({
to: to,
value: value, // Timelock based on this value
data: data,
confirmations: 0,
proposedAt: block.timestamp,
executed: false
});
// ...
}
function _getTimelockDelay(uint256 value) internal pure returns (uint256) {
// Delays based on value tiers
if (value >= 100 ether) return SEVEN_DAYS_TIME_DELAY;
else if (value >= 10 ether) return TWO_DAYS_TIME_DELAY;
else if (value >= 1 ether) return ONE_DAY_TIME_DELAY;
else return NO_TIME_DELAY;
}

Recommended Mitigation

//Implement cumulative value tracking or rate limiting:
mapping(address => uint256) private s_cumulativeValueByRecipient;
mapping(address => uint256) private s_lastExecutionTime;
uint256 private constant RATE_LIMIT_WINDOW = 1 days;
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// Check cumulative value in time window
if (block.timestamp - s_lastExecutionTime[txn.to] < RATE_LIMIT_WINDOW) {
uint256 totalValue = s_cumulativeValueByRecipient[txn.to] + txn.value;
uint256 requiredDelay = _getTimelockDelay(totalValue);
require(block.timestamp >= txn.proposedAt + requiredDelay, "Cumulative timelock not met");
}
// Update tracking
s_cumulativeValueByRecipient[txn.to] += txn.value;
s_lastExecutionTime[txn.to] = block.timestamp;
// ...
}

Support

FAQs

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

Give us feedback!