Root + Impact
Due to absence of validation preventing self‑calls during transaction proposal or execution. The contract can execute its own external functions
Description
The timelock’s MultiSigTimelock::_executeTransaction function allows arbitrary calls via:.call. There is no restriction preventing txn.to from being the timelock contract itself (address(this)). This means signers can propose and execute transactions that call back into the timelock’s own functions. Such self‑targeted execution erodes the boundary between governance and execution, enabling recursive calls, denial‑of‑service scenarios, or governance self‑mutation.
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);
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}
Risk
Likelihood:
Impact:
Signers can schedule transactions that call grantRole, revokeRole, or other admin functions on the timelock itself, mutating governance in unexpected ways.
Proof of Concept
Signers propose a transaction
Transaction passes quorum + timelock
During execution:
- A signer can propose a transaction where the target address (txn.to) is the timelock contract itself.
- The calldata of that transaction can be crafted to call one of the timelock’s own public functions (for example, grantRole).
- Other signers confirm the transaction until quorum is reached.
- After the timelock delay expires, any signer can execute it.
- When executed, the timelock ends up calling itself and successfully runs the internal function — in the PoC, this resulted in granting a new role to an account.
Recommended Mitigation
Reject self-targeted transactions.
Can also put in proposeTransaction This will prevents such transactions from ever being queued.
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);
}
+
require(txn.to != address(this), "Cannot target timelock contract itself");
txn.executed = true;
(bool success,) = payable(txn.to).call{value: txn.value}(txn.data);
if (!success) {
revert MultiSigTimelock__ExecutionFailed();
}
emit TransactionExecuted(txnId, txn.to, txn.value);
}