MultiSig Timelock

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

Permanent wallet bricking as `revokeSigningRole` allows reducing signer count below `REQUIRED_CONFIRMATIONS`

Author Revealed upon completion

Root + Impact

Description

  • The contract is designed to manage funds through a multi-signature mechanism where a fixed quorum of 3 confirmations is required to execute any transaction.

  • The revokeSigningRole function fails to validate that the number of remaining signers is at least equal to the required quorum. It only checks if the count is greater than 1, allowing the owner to reduce the signer pool to 2 or 1, making the REQUIRED_CONFIRMATIONS limit unreachable.

function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
// ... (checks)
@> if (s_signerCount <= 1) { // Root cause: logic allows count to drop below 3
revert MultiSigTimelock__CannotRevokeLastSigner();
}
// ... (removal logic)
}

Risk

Likelihood:

  • This occurs whenever the owner manages the signer list and removes participants due to compromise, inactivity, or rotation.

  • There are no secondary mechanisms to reduce the REQUIRED_CONFIRMATIONS constant, making the mistake irreversible.

Impact:

  • Total loss of access to all funds held in the contract.

  • Permanent Denial of Service (DoS) for the wallet's core functionality (executeTransaction).

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_Bricking is Test {
MultiSigTimelock public multiSig;
address public OWNER = address(this);
address public SIGNER_2 = makeAddr("signer2");
address public SIGNER_3 = makeAddr("signer3");
function setUp() public {
multiSig = new MultiSigTimelock();
multiSig.grantSigningRole(SIGNER_2);
multiSig.grantSigningRole(SIGNER_3);
vm.deal(address(multiSig), 10 ether);
}
function test_BrickingTheWallet() public {
assertEq(multiSig.getSignerCount(), 3);
console.log("Initial Signers:", multiSig.getSignerCount());
multiSig.revokeSigningRole(SIGNER_3);
console.log("Signers after revocation:", multiSig.getSignerCount());
assertEq(multiSig.getSignerCount(), 2);
address recipient = makeAddr("recipient");
uint256 txnId = multiSig.proposeTransaction(recipient, 1 ether, "");
multiSig.confirmTransaction(txnId);
vm.prank(SIGNER_2);
multiSig.confirmTransaction(txnId);
vm.expectRevert();
multiSig.executeTransaction(txnId);
console.log("Transaction blocked! Quorum of 3 is impossible with 2 signers.");
}
}

Run:

forge test --match-path test/PoC_Bricking.t.sol -vv

Output:

[PASS] test_BrickingTheWallet() (gas: 232004)
Logs:
Initial Signers: 3
Signers after revocation: 2
Transaction blocked! Quorum of 3 is impossible with 2 signers.

Recommended Mitigation

- if (s_signerCount <= 1) {
- revert MultiSigTimelock__CannotRevokeLastSigner();
- }
+ if (s_signerCount <= REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__InsufficientSignersToMaintainQuorum();
+ }

Support

FAQs

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

Give us feedback!