Able Tan Jaguar
Medium
There's a mismatch between gas limit validation on L1 and actual gas consumption on L2, which could lead to message execution failures and subsequent message accumulation, this allow messages to pass the gas limit checks on L1 but fail during execution on L2 due to insufficient gas. Although the system incorporates fee requirements and relies on trusted sequencers, for mitigation against abuse, but the risk of gradual system congestion remains a concern. This issue doesn't present an immediate threat of system failure or fund loss but could lead to degraded performance and increased operational overhead if left unaddressed.
Messages can pass the initial gas limit checks on L1, but fail during execution on L2 due to insufficient gas, and then remain in the system as skipped messages, this failed messages can lead to congestion. The vulnerability arises due to the interaction between gas limit validation on L1, message execution on L2, and the handling of failed messages.
function sendMessage(
address _to,
uint256 _value,
bytes memory _message,
uint256 _gasLimit
) external payable override whenNotPaused {
_sendMessage(_to, _value, _message, _gasLimit, _msgSender());
}
/// @inheritdoc ICrossDomainMessenger
function sendMessage(
address _to,
uint256 _value,
bytes calldata _message,
uint256 _gasLimit,
address _refundAddress
) external payable override whenNotPaused {
_sendMessage(_to, _value, _message, _gasLimit, _refundAddress);
}
This is where messages are initiated, but in L1MessageQueueWithGasPriceOracle
that validate the gas limit as follows
function appendCrossDomainMessage(
address _target,
uint256 _gasLimit,
bytes calldata _data
) external override onlyMessenger {
// validate gas limit
_validateGasLimit(_gasLimit, _data);
// do address alias to avoid replay attack in L2.
address _sender = AddressAliasHelper.applyL1ToL2Alias(_msgSender());
_queueTransaction(_sender, _target, 0, _gasLimit, _data);
}
function _validateGasLimit(uint256 _gasLimit, bytes calldata _calldata) internal view {
require(_gasLimit <= maxGasLimit, "Gas limit must not exceed maxGasLimit");
// check if the gas limit is above intrinsic gas
uint256 intrinsicGas = calculateIntrinsicGasFee(_calldata);
require(_gasLimit >= intrinsicGas, "Insufficient gas limit, must be above intrinsic gas");
}
Gas limit validation occurs, but only checks against a minimum and maximum intrinsic limit
function _queueTransaction(
address _sender,
address _target,
uint256 _value,
uint256 _gasLimit,
bytes calldata _data
) internal {
// compute transaction hash
uint256 _queueIndex = messageQueue.length;
bytes32 _hash = computeTransactionHash(_sender, _queueIndex, _value, _target, _gasLimit, _data);
messageQueue.push(_hash);
// emit event
emit QueueTransaction(_sender, _target, _value, uint64(_queueIndex), _gasLimit, _data);
}
Messages are queued after passing validation.
function _executeMessage(
address _from,
address _to,
uint256 _value,
bytes memory _message,
bytes32 _xDomainCalldataHash
) internal {
// @note check more `_to` address to avoid attack in the future when we add more gateways.
require(_to != Predeploys.L2_TO_L1_MESSAGE_PASSER, "Forbid to call l2 to l1 message passer");
_validateTargetAddress(_to);
// @note This usually will never happen, just in case.
require(_from != xDomainMessageSender, "Invalid message sender");
xDomainMessageSender = _from;
// solhint-disable-next-line avoid-low-level-calls
(bool success, ) = _to.call{value: _value}(_message);
// reset value to refund gas.
xDomainMessageSender = Constants.DEFAULT_XDOMAIN_MESSAGE_SENDER;
if (success) {
isL1MessageExecuted[_xDomainCalldataHash] = true;
emit RelayedMessage(_xDomainCalldataHash);
} else {
emit FailedRelayedMessage(_xDomainCalldataHash);
}
}
Messages are executed here but If gas is insufficient, the call fails.
function popCrossDomainMessage(uint256 _startIndex, uint256 _count, uint256 _skippedBitmap) external {
require(_msgSender() == ROLLUP_CONTRACT, "Only callable by the rollup");
require(_count <= 256, "pop too many messages");
require(pendingQueueIndex == _startIndex, "start index mismatch");
unchecked {
// clear extra bits in `_skippedBitmap`, and if _count = 256, it's designed to overflow.
uint256 mask = (1 << _count) - 1;
_skippedBitmap &= mask;
uint256 bucket = _startIndex >> 8;
uint256 offset = _startIndex & 0xff;
skippedMessageBitmap[bucket] |= _skippedBitmap << offset;
if (offset + _count > 256) {
skippedMessageBitmap[bucket + 1] = _skippedBitmap >> (256 - offset);
}
pendingQueueIndex = _startIndex + _count;
}
emit DequeueTransaction(_startIndex, _count, _skippedBitmap);
}
Failed messages are marked as skipped but remain in the system
Heres how attacker can exploit this vulnerability through the following steps:
-
The attacker prepares multiple cross-chain messages with gas limits that are just above the minimum required by _validateGasLimit but insufficient for actual execution on L2.
-
The attacker sends these messages using sendMessage in L1CrossDomainMessenger, paying the required fees.
-
These messages will pass the gas limit validation in _validateGasLimit but will be queued via _queueTransaction.
-
When these messages are relayed to L2, the message will fail in _executeMessage due to insufficient gas
-
Even though the failed messages are marked as skipped in popCrossDomainMessage, but they're not removed from the system.
-
This process can be repeated, leading to an accumulation of failed messages in the system.
-
Message Queue Congestion: The accumulation of failed messages could lead to congestion in the message queue, potentially delaying the processing of legitimate messages.
-
Increased Operational Overhead: More resources would be required to manage and clear the backlog of failed messages.
-
Network Congestion
-
Improve Gas Estimation: Implement more accurate gas estimation for L2 execution in the L1 validation process.
-
Batch Processing of Failed Messages: Develop mechanisms to efficiently process or clear backlogs of failed messages.