MultiSig Timelock

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

Arbitrary call data execution under low-value transaction.

Author Revealed upon completion

Unrestricted calldata allow high-impact execution under low-value timelock

Description

  • The MultiSigTimelock contract allows the owner to propose arbitrary transactions containing an unrestricted bytes data.

    Signers are required to confirm transactions, however:

    • The contract does not enforce any semantic checks on the calldata

    • The risk of a transaction is implicitly inferred only from the ETH value, not from the actual logic executed

    • Transactions with low or zero ETH value can still execute high-impact contract calls

    As a result, signers may approve transactions that appear small (for example 0.1 ETH) while the calldata performs dangerous or irreversible actions, such as:


    • Granting approvals

    • Upgrading contracts

    • Changing critical configuration

    • Transferring tokens

    • Executing arbitrary external callS

    The protocol relies entirely on off-chain human verification of calldata, which is unsafe and error-prone

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
uint256 requiredDelay = _getTimelockDelay(txn.value); // @> VALUE ONLY
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data); // @> ARBITRARY CALL
}

No data validation :

function proposeTransaction(
address to,
uint256 value,
bytes calldata data
) external onlyOwner returns (uint256)

Risk

Likelihood :

  • Signers approve transactions based on ETH value and recipient, not raw calldata

  • Raw calldata inspection is manual, off-chain, and error‑prone, especially for non‑trivial bytecode

Impact:

  • Signers are able to approve transactions hidden by a low‑value operations.

  • Funds or critical contract state can be compromised without violating multisig rules


    Proof of Concept

Put the following code into test/unit/MultiSigTimelockTest.t.sol

function test_PoC_LowValueTxExecutesMaliciousCalldata() public {
// Actors
address attacker = makeAddr("attacker");
address signer1 = makeAddr("signer1");
address signer2 = makeAddr("signer2");
address signer3 = makeAddr("signer3");
// Deploy token & multisig
MockToken token = new MockToken();
MultiSigTimelock multiSig = new MultiSigTimelock();
// Fund multisig with tokens
token.transfer(address(multiSig), 100_000 ether);
// Setup signers
vm.prank(multiSig.owner());
multiSig.grantSigningRole(signer1);
vm.prank(multiSig.owner());
multiSig.grantSigningRole(signer2);
vm.prank(multiSig.owner());
multiSig.grantSigningRole(signer3);
// Malicious calldata: approve attacker for unlimited tokens
bytes memory maliciousData = abi.encodeWithSignature(
"approve(address,uint256)",
attacker,
type(uint256).max
);
// Owner proposes LOW VALUE tx (< 1 ETH → NO TIMELOCK)
vm.prank(multiSig.owner());
uint256 txId = multiSig.proposeTransaction(
address(token),
0.1 ether,
maliciousData
);
// Signers confirm (they only see "0.1 ETH transfer")
vm.prank(signer1);
multiSig.confirmTransaction(txId);
vm.prank(signer2);
multiSig.confirmTransaction(txId);
vm.prank(signer3);
multiSig.confirmTransaction(txId);
// Execute immediately
vm.prank(signer1);
multiSig.executeTransaction(txId);
// Exploit: attacker drains tokens
vm.prank(attacker);
token.transferFrom(
address(multiSig),
attacker,
100_000 ether
);
assertEq(token.balanceOf(attacker), 100_000 ether);
}
forge test --mt test_PoC_LowValueTxExecutesMaliciousCalldata -vvv
  • A low‑value transaction passes confirmation and timelock checks while executing high‑impact logic via calldata.

  • The contract executes the malicious action without any on‑chain detection or prevention.


Recommended Mitigation

Do not derive timelock duration solely from txn.value.
Instead, enforce a minimum timelock for any transaction that contains non-empty calldata.

Any calldata can encode arbitrary logic and should never be treated as “low-risk” regardless of ETH value.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
- uint256 requiredDelay = _getTimelockDelay(txn.value);
+ uint256 requiredDelay = txn.data.length > 0
+ ? MIN_DATA_TIMELOCK
+ : _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
require(success, "Execution failed");
}

Support

FAQs

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

Give us feedback!