MultiSig Timelock

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

No upper bound on data payload

Author Revealed upon completion

Description

  • A secure multisig should constrain or validate the size of the calldata forwarded to external contracts (and/or cap the gas forwarded), to prevent gas griefing, memory expansion explosions, and unexpected behavior when interacting with untrusted targets.

  • The wallet stores and forwards arbitrary‑length bytes data and executes it via a low‑level .call() without any bound or per‑target constraint. Extremely large payloads inflate storage and memory costs, can cause out‑of‑gas during execution (especially with memory expansion), and enable griefing or unexpected behavior when calling malicious contracts.

function _proposeTransaction(address to, uint256 value, bytes memory data) internal returns (uint256) {
uint256 transactionId = s_transactionCount;
s_transactions[transactionId] = Transaction({
to: to,
value: value,
data: data, // @> Arbitrary-length data stored in contract state
confirmations: 0,
proposedAt: block.timestamp,
executed: false
});
s_transactionCount++;
emit TransactionProposed(transactionId, to, value);
return transactionId;
}
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
// ...
// @> Forwards the entire data blob with all remaining gas to an untrusted target
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
emit TransactionExecuted(txnId, txn.to, txn.value);
}

Risk

Likelihood: Medium

  • In normal treasury operations, signers often propose data‑only calls (approvals, swaps, staking, governance actions). Without a bound, a signer can submit very large payloads; the proposal will be stored and later executed.

  • Interacting with third‑party contracts (including malicious or upgradable ones) is common; forwarding unbounded data and all gas increases the chance of griefing and non‑deterministic failures.

Impact: Medium

  • Gas griefing / DoS: Huge calldata triggers expensive storage writes on proposal and expensive memory copies on execution, potentially hitting block gas limits or causing repeated OOG when anyone tries to execute.

  • Unexpected behavior: Malicious targets can exploit large input sizes to induce heavy processing, subtle reverts, or pathological memory usage, complicating incident response and monitoring.

Proof of Concept

  • Copy the code below to MultiSigTimeLockTest.t.sol.

  • Run command forge test --mt testUnboundedDataPayloadAllowsGasGriefing -vv.

function testUnboundedDataPayloadAllowsGasGriefing() public {
// Create a very large payload (e.g., 200k bytes). Size chosen to illustrate risk; no bound exists.
bytes memory bigData = new bytes(200_000);
// Propose a data-only transaction (value == 0) to a benign recipient.
// In the current contract, this stores the entire blob on-chain and later forwards it as-is.
uint256 gasBefore = gasleft();
uint256 txnId = multiSigTimelock.proposeTransaction(SPENDER_ONE, 0, bigData);
uint256 gasAfter = gasleft();
uint256 gasUsed = gasBefore - gasAfter;
console2.log("Gas used to propose transaction with large data payload:", gasUsed);
// Grant two more signers and reach quorum
multiSigTimelock.grantSigningRole(SIGNER_TWO);
multiSigTimelock.grantSigningRole(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_THREE);
multiSigTimelock.confirmTransaction(txnId);
// Attempt execution: this forwards the full 200k-byte payload via .call(),
// consuming very large gas due to memory expansion. Depending on limits,
// this may succeed at high cost or repeatedly OOG, demonstrating grief potential.
gasBefore = gasleft();
multiSigTimelock.executeTransaction(txnId);
gasAfter = gasleft();
gasUsed = gasBefore - gasAfter;
console2.log("Gas used to execute transaction with large data payload:", gasUsed);
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/unit/MultiSigTimelockTest.t.sol:MultiSigTimeLockTest
[PASS] testUnboundedDataPayloadAllowsGasGriefing() (gas: 16461247)
Logs:
Gas used to propose transaction with large data payload: 14833896
Gas used to execute transaction with large data payload: 1252526
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 16.35ms (13.65ms CPU time)
Ran 1 test suite in 17.99ms (16.35ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Introduce explicit payload size limits and, optionally, per‑target constraints and gas caps:

+ // Add an upper bound on calldata size
+ uint256 private constant MAX_DATA_LENGTH = 4096; // e.g., 4 KB
+ // Custom error
+ error MultiSigTimelock__DataTooLong(uint256 length, uint256 max);
function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
onlyOwner
returns (uint256)
{
+ // Enforce size limit at proposal time to prevent storing huge blobs on-chain
+ if (data.length > MAX_DATA_LENGTH) {
+ revert MultiSigTimelock__DataTooLong(data.length, MAX_DATA_LENGTH);
+ }
return _proposeTransaction(to, value, data);
}
function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
+ // Defensive check at execution time (covers legacy stored txns or future policy changes)
+ if (txn.data.length > MAX_DATA_LENGTH) {
+ revert MultiSigTimelock__DataTooLong(txn.data.length, MAX_DATA_LENGTH);
+ }
- (bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
+ // Optional: cap gas forwarded to the target to limit griefing (tune carefully)
+ uint256 EXEC_GAS_LIMIT = 500_000; // example value; ensure legitimate calls still fit
+ (bool success,) = payable(txn.to).call{value: txn.value, gas: EXEC_GAS_LIMIT}(txn.data);
if (!success) { revert MultiSigTimelock__ExecutionFailed(); }
emit TransactionExecuted(txnId, txn.to, txn.value);
}

Support

FAQs

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

Give us feedback!