MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

19Dec2025_AuditReport5_MultiSigTimelock

Root + Impact

Description

  • Transaction Can Be Executed With Insufficient Balance Check Bypass

  • The balance check in _executeTransaction occurs after checking confirmations and timelock but before marking the transaction as executed. An attacker could potentially exploit this by having multiple transactions checking the same balance simultaneously, or by manipulating the contract balance between the check and the actual transfer through forced Ether sends or other mechanisms.

// Root cause in the codebase with @> marks to highlight the relevant section
// Assume contract has 10 ETH
// Transaction 1: Send 8 ETH (confirmed and ready)
// Transaction 2: Send 8 ETH (confirmed and ready)
// Both signers call executeTransaction in the same block
// Both could pass the balance check before either executes
// First succeeds, second fails with unclear state

Risk

Likelihood:

  • The balance check in _executeTransaction occurs after checking confirmations and timelock but before marking the transaction as executed. An attacker could potentially exploit this by having multiple transactions checking the same balance simultaneously, or by manipulating the contract balance between the check and the actual transfer through forced Ether sends or other mechanisms.

Impact:

  • While the nonReentrant modifier prevents direct reentrancy, if multiple signers attempt to execute different high-value transactions simultaneously in separate transactions, race conditions could occur where both pass the balance check but the second fails during execution.

Proof of Concept

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// ... other checks ...
if (txn.value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
// EFFECTS
txn.executed = true; // State change happens here
// INTERACTIONS
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
// ...
}

Recommended Mitigation

// Consider using a withdrawal pattern or reserve system:
mapping(uint256 => bool) private s_fundsReserved;
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// Reserve funds atomically
require(!s_fundsReserved[txnId], "Funds already reserved");
require(txn.value <= address(this).balance, "Insufficient balance");
s_fundsReserved[txnId] = true;
// Mark as executed
txn.executed = true;
// Execute
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
// ...
}
Updates

Lead Judging Commences

kelechikizito Lead Judge 4 days ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!