MultiSig Timelock

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

Timelock can be bypassed by executing token transfers/approvals with zero ETH value

Author Revealed upon completion

Root + Impact

Description

Normal behavior (expected)

The timelock mechanism should delay high-impact withdrawals/actions from the multisig so signers have time to notice and react before funds or valuable assets are moved.

Actual behavior (bug)

The timelock delay is calculated only from the ETH value (txn.value), but the wallet can execute arbitrary calldata (txn.data) with value = 0. As a result, a transaction that transfers ERC20/ERC721 assets (or grants token approvals) can be executed immediately with no timelock, even if the economic value is extremely large.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// ...
uint256 requiredDelay = _getTimelockDelay(txn.value); // @> Timelock uses ONLY ETH value
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data); // @> Arbitrary call can move tokens with value=0
if (!success) revert MultiSigTimelock__ExecutionFailed();
}

Risk

Likelihood:

  • The contract explicitly supports arbitrary contract calls through bytes data in proposals and uses low-level call to execute them.

  • The issue triggers whenever the wallet holds ERC20/ERC721/ERC1155 tokens (or has valuable permissions in other contracts), because those assets can be moved via calldata with txn.value = 0.

Impact:

  • The intended timelock protection can be bypassed, allowing immediate execution of high-value token/NFT withdrawals or approvals.

  • Non-ETH assets held by the wallet can be drained without delay once quorum (3 confirmations) is reached.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract MockERC20 {
mapping(address => uint256) public balanceOf;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
require(balanceOf[msg.sender] >= amount, "no bal");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
}
contract H01_TimelockBypass_NonEthValueActions is Test {
MultiSigTimelock wallet;
MockERC20 token;
address SIGNER2 = address(0xBEEF);
address SIGNER3 = address(0xCAFE);
address ATTACKER = address(0xD00D);
function setUp() public {
wallet = new MultiSigTimelock(); // owner = this contract; owner is also a signer
wallet.grantSigningRole(SIGNER2);
wallet.grantSigningRole(SIGNER3);
token = new MockERC20();
token.mint(address(wallet), 1_000_000 ether); // large treasury balance
}
function test_H01_valueZero_tokenTransfer_executesImmediately() public {
// Propose: call token.transfer(attacker, 1_000_000e18) with ETH value = 0
bytes memory data = abi.encodeWithSelector(token.transfer.selector, ATTACKER, 1_000_000 ether);
uint256 txnId = wallet.proposeTransaction(address(token), 0, data);
// Collect quorum = 3 confirmations
wallet.confirmTransaction(txnId);
vm.prank(SIGNER2);
wallet.confirmTransaction(txnId);
vm.prank(SIGNER3);
wallet.confirmTransaction(txnId);
// Execute immediately (no vm.warp); succeeds because delay is based on txn.value == 0
wallet.executeTransaction(txnId);
assertEq(token.balanceOf(ATTACKER), 1_000_000 ether);
assertEq(token.balanceOf(address(wallet)), 0);
}
}

This PoC mints a large ERC20 balance to the MultiSigTimelock wallet, then proposes an ERC20 transfer() using txn.value = 0 and calldata in txn.data. After collecting the required 3 confirmations, it executes the transaction immediately without any time warp, proving the timelock is bypassed because delay is calculated only from ETH value. The final assertions show the attacker receives all tokens while the wallet’s token balance becomes zero.

Recommended Mitigation

Apply a baseline timelock for any transaction that carries calldata (i.e., any contract call), not only ETH value transfers. For example, enforce a minimum 1-day delay whenever txn.data.length != 0.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// ...
- uint256 requiredDelay = _getTimelockDelay(txn.value);
+ uint256 requiredDelay = _getTimelockDelay(txn.value);
+ // Ensure arbitrary contract calls (token transfers/approvals/NFT moves) cannot bypass timelock using value=0
+ if (txn.data.length > 0 && requiredDelay < 1 days) {
+ requiredDelay = 1 days;
+ }
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
// ...
}

Support

FAQs

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

Give us feedback!