MultiSig Timelock

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

Denial of Service (DoS): Deadllock when Signer Count drop below REQUIRED_CONFIRMATION

Author Revealed upon completion

Root + Impact

Permanent Loss of Funds. Once the signer count drops below 3, no transaction (including those to send ETH, call external contracts, or even grantSigningRole to fix the state) can be executed. Any ETH stored in the contract is permanently trapped.

Description

  • The MultiSigTimelock contract has a hardcoded quorum requirement defined by REQUIRED_CONFIRMATIONS = 3. However, the revokeSigningRole function only prevents the owner from revoking the last signer.

  • Because this check does not account for the REQUIRED_CONFIRMATIONS constant, the owner can successfully revoke signers until only 2 remain. Since a transaction requires 3 confirmations to execute, and there are only 2 signers left in the system, it becomes mathematically impossible to reach quorum.This results in a permanent Deadlock of the contract.

function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
// CHECKS
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
// Prevent revoking the first signer (would break the multisig), moreover, the first signer is the owner of the contract(wallet)
@> if (s_signerCount <= 1) {
revert MultiSigTimelock__CannotRevokeLastSigner();
}
// Find the index of the account in the array
uint256 indexToRemove = type(uint256).max; // Use max as "not found" indicator
for (uint256 i = 0; i < s_signerCount; i++) {
if (s_signers[i] == _account) {
indexToRemove = i;
break;
}
}
// Gas-efficient array removal: move last element to removed position
if (indexToRemove < s_signerCount - 1) {
// Move the last signer to the position of the removed signer
s_signers[indexToRemove] = s_signers[s_signerCount - 1];
}
// Clear the last position and decrement count
s_signers[s_signerCount - 1] = address(0);
s_signerCount -= 1;
s_isSigner[_account] = false;
_revokeRole(SIGNING_ROLE, _account);
}

Risk

mpact:

  • Permanent Loss of Funds. Once the signer count drops below 3, no transaction (including those to send ETH, call external contracts, or even grantSigningRole to fix the state) can be executed. Any ETH stored in the contract is permanently trapped.

Proof of Concept

This POC prove that the OWNER can reduce the signer count to 2, at which point the wallet becomes unusable.

1. Confirm starting state

2. OWNER revokes SIGNER_THREE

3. Prove the invariant is broken

4. Prove execution is impossible

Add below code into MultiSigTimelockTest.t.sol

pragma solidity ^0.8.19;
import {Test, console2} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
import {EthRejector} from "test/utils/EthRejector.sol";
import {TestTimelockDelay} from "test/utils/TestTimelockDelay.sol";
import {DeployMultiSigTimelock} from "script/DeployMultiSigTimelock.s.sol";
import {GrantSigningRole} from "script/GrantSigningRole.s.sol";
import {ProposeTransactionScript} from "script/Interact.s.sol";
import {ConfirmTransactionScript} from "script/Interact.s.sol";
import {ExecuteTransactionScript} from "script/Interact.s.sol";
contract MultiSigTimeLockTest is Test {
MultiSigTimelock multiSigTimelock;
EthRejector ethRejector;
TestTimelockDelay testTimelockDelay;
DeployMultiSigTimelock deployer;
GrantSigningRole grantor;
address public OWNER = address(this);
address public SIGNER_TWO = makeAddr("signer_two");
address public SIGNER_THREE = makeAddr("signer_three");
address public SIGNER_FOUR = makeAddr("signer_four");
address public SIGNER_FIVE = makeAddr("signer_five");
address public SPENDER_ONE = makeAddr("spender_one");
address public SPENDER_TWO = makeAddr("spender_two");
function setUp() public {
multiSigTimelock = new MultiSigTimelock();
ethRejector = new EthRejector();
// Fund the contract
vm.deal(address(multiSigTimelock), 100 ether);
}
function test_Audit_LivenessInvariantBroken() public {
address[] memory signersArray = new address[](5);
signersArray[0] = OWNER;
multiSigTimelock.grantSigningRole(SIGNER_TWO);
signersArray[1] = SIGNER_TWO;
multiSigTimelock.grantSigningRole(SIGNER_THREE);
signersArray[2] = SIGNER_THREE;
// Confirm we start in a valid state
assertEq(multiSigTimelock.getSignerCount(), 3);
assertEq(multiSigTimelock.getRequiredConfirmations(), 3);
// OWNER revokes SIGNER_THREE.
// The current contract logic only prevents revoking the LAST signer (count <= 1).
// It does NOT prevent revoking below the REQUIRED_CONFIRMATIONS (3).
multiSigTimelock.revokeSigningRole(SIGNER_THREE);
// Verify the “Deadlock” state
uint256 totalSigners = multiSigTimelock.getSignerCount();
uint256 threshold = multiSigTimelock.getRequiredConfirmations();
console2.log("signers count after revocation: ", totalSigners);
console2.log("Minimum Threshold required: ", threshold);
// Assert the invariant is broken: Signers (2) < Threshold (3)
assertTrue(totalSigners < threshold, "Total number of signers is less than required threshold");
// Prove execution is impossible
// Owner Propose a transaction (value = 0.5 ETH for no timelock delay)
uint256 txId = multiSigTimelock.proposeTransaction(address(ethRejector), 0.5 ether, "");
// All remaining signers (Owner and Signer Two) confirm
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId);
// Even though 100% of the active signers have confirmed, execution fails.
// The contract is now a “brick” and funds are lost.
vm.expectRevert();
multiSigTimelock.executeTransaction(txId);
console2.log("Result: Transaction Failed. 100 ether is Permanently locked.");
}
}

Recommended Mitigation

Update the revokeSigningRole function to prevent the signer count from ever falling below the required execution threshold.

function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
// Prevent revoking the first signer (would break the multisig), moreover, the first signer is the owner of the contract(wallet)
- if (s_signerCount <= 1) {
- revert MultiSigTimelock__CannotRevokeLastSigner();
- }
+ if (s_signerCount <= REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__CannotRevokeBelowQuorum();
+ }
// Find the index of the account in the array
uint256 indexToRemove = type(uint256).max; // Use max as "not found" indicator
for (uint256 i = 0; i < s_signerCount; i++) {
if (s_signers[i] == _account) {
indexToRemove = i;
break;
}
}
// Gas-efficient array removal: move last element to removed position
if (indexToRemove < s_signerCount - 1) {
// Move the last signer to the position of the removed signer
s_signers[indexToRemove] = s_signers[s_signerCount - 1];
}
// Clear the last position and decrement count
s_signers[s_signerCount - 1] = address(0);
s_signerCount -= 1;
s_isSigner[_account] = false;
_revokeRole(SIGNING_ROLE, _account);
}

Support

FAQs

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

Give us feedback!