Root + Impact
Root Cause
The vulnerability is a logic mismatch between the fixed quorum and dynamic membership :
-
Fixed Quorum : REQUIRED_CONFIRMATIONS is hardcoded to 3
-
Insufficient Validation: revokeSigningRole only prevents the signer count from reaching 1, but allows it to drop to 2
-
The Deadlock: Once signers drop below 3, the condition confirmations >= 3 becomes impossible to satisfy, permanently bricking the contract.
Description
-
The MultiSigTimelock contract implements a multi-signature mechanism with a hardcoded quorum of 3 confirmations. However, the revokeSigningRole function lacks a safety check to ensure the total number of signers remains equal to or greater than this required threshold.
-
If an admin revokes a signer and reduces the total count to 2, the contract enters an irreversible "bricked" state. Since no transaction can ever collect 3 signatures, it becomes impossible to execute any further actions—including adding new signers or withdrawing funds—leading to a permanent loss of all assets held in the contract.
@> if (s_signerCount <= 1) {
@> revert MultiSigTimelock__CannotRevokeLastSigner();
@> }
Risk
Likelihood:
Impact:
Proof of Concept
function test_BrickContractViaRevoke() public {
vm.startPrank(OWNER);
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
vm.stopPrank();
assertEq(multiSigTimelock.getSignerCount(), 3);
vm.prank(OWNER);
multiSigTimelock.revokeSigningRole(SIGNER_THREE);
assertEq(multiSigTimelock.getSignerCount(), 2);
console2.log("SignerCount is now 2. Remaining: Owner and Signer_Two");
vm.prank(OWNER);
uint256 txId = multiSigTimelock.proposeTransaction(SPENDER_ONE, 1 ether, "");
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId);
vm.expectRevert();
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txId);
console2.log("Bug Confirmed: Contract is bricked. Funds are locked forever!");
}
Add the Test: Copy the provided test_BrickContractViaRevoke function into your test suite (e.g., test/unit/MultiSigTimelockTest.t.sol).
Run the Test: Execute the following command in your terminal:
forge test --mt test_BrickContractViaRevoke -vvvv
Expected Output
git:(main*)$ forge test --mt test_BrickContractViaRevoke -vvv
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/unit/MultiSigTimelockTest.t.sol:MultiSigTimeLockTest
[PASS] test_BrickContractViaRevoke() (gas: 316536)
Logs:
SignerCount is now 2. Remaining: Owner and Signer_Two
Bug Confirmed: Contract is bricked. Funds are locked forever!
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.28ms (2.79ms CPU time)
Ran 1 test suite in 99.47ms (16.28ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
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();
- }
+ // Ensure the number of signers never drops below the required quorum (3)
+ // This prevents a logical deadlock where transactions can no longer be executed.
+ if (s_signerCount <= REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__InsufficientSignersForQuorum();
+ }
// Find the index of the account in the array
// ... rest of the code