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);
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);
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
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();
wallet.grantSigningRole(SIGNER2);
wallet.grantSigningRole(SIGNER3);
token = new MockERC20();
token.mint(address(wallet), 1_000_000 ether);
}
function test_H01_valueZero_tokenTransfer_executesImmediately() public {
bytes memory data = abi.encodeWithSelector(token.transfer.selector, ATTACKER, 1_000_000 ether);
uint256 txnId = wallet.proposeTransaction(address(token), 0, data);
wallet.confirmTransaction(txnId);
vm.prank(SIGNER2);
wallet.confirmTransaction(txnId);
vm.prank(SIGNER3);
wallet.confirmTransaction(txnId);
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);
}
// ...
}