Root + Impact
Description
Static Accounting: confirmTransaction increments a standalone integer (confirmations) that is decoupled from the signer’s future authorization status.
Stale State: revokeSigningRole removes a signer's role but fails to decrement counters in pending transactions. The revoked signer’s approval remains "locked" as a valid vote.
Unauthorized Execution: executeTransaction only checks if the static counter >= 3. This allows transactions to pass quorum using "ghost" votes from revoked members, bypassing the 3-of-N active signer requirement.
function _confirmTransaction(uint256 txnId) internal {
s_signatures[txnId][msg.sender] = true;
@> s_transactions[txnId].confirmations++;
emit TransactionConfirmed(txnId, msg.sender);
}
function revokeSigningRole(address _account) external onlyOwner {
s_signerCount -= 1;
s_isSigner[_account] = false;
@> _revokeRole(SIGNING_ROLE, _account);
}
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
@> if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
}
Risk
Likelihood:
Impact:
Proof of Concept
function test_GhostSignaturePersistence() public {
vm.startPrank(OWNER);
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
vm.stopPrank();
vm.deal(address(multiSigTimelock), 2 ether);
vm.prank(OWNER);
uint256 txId = multiSigTimelock.proposeTransaction(SPENDER_ONE, 1 ether, "");
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txId);
vm.prank(OWNER);
multiSigTimelock.revokeSigningRole(SIGNER_THREE);
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txId);
vm.warp(block.timestamp + 1 days + 1 seconds);
vm.prank(OWNER);
multiSigTimelock.executeTransaction(txId);
MultiSigTimelock.Transaction memory txn = multiSigTimelock.getTransaction(txId);
assertTrue(txn.executed);
assertEq(SPENDER_ONE.balance, 1 ether);
console2.log("Success! The test passed and the vulnerability is proven.");
}
Add the Test: Copy the provided test_GhostSignaturePersistence 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_GhostSignaturePersistence -vvvv
Recommended Mitigation
-
Execution quorum is derived from the current signer set, not from stale state.
-
Once a signer is revoked, all their historical approvals become invalid instantly.
-
revokeSigningRole no longer needs to handle legacy transaction state.
// 1. Data Structure Fix
- Store confirmation count inside the Transaction struct.
+ Remove the `confirmations` field entirely from the Transaction struct.
// 2. Execution Logic Fix
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
- if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
- revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
- }
+ uint256 validConfirmations;
+ for (uint256 i = 0; i < s_signerCount; i++) {
+ address signer = s_signers[i];
+ if (s_signatures[txnId][signer]) {
+ validConfirmations++;
+ }
+ }
+
+ if (validConfirmations < REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__InsufficientConfirmations(
+ REQUIRED_CONFIRMATIONS,
+ validConfirmations
+ );
+ }
// ... remaining execution logic
}
// 3. Confirmation Logic Simplification
function _confirmTransaction(uint256 txnId) internal {
- s_transactions[txnId].confirmations++;
}
function _revokeConfirmation(uint256 txnId) internal {
- s_transactions[txnId].confirmations--;
}