Outgoing transactions to an external EVM chain can be maliciously blocked by crafting a cctx that can not be broadcasted, i.e., causing the RPC to error (with an error that is not handled in the HandleBroadcastError function).
For example, causing the intrinsic gas limit to exceed the provided gas limit (minimum 100k) prevents the transaction from being included in the EVM mempool and blocks the queue of pending outgoing transactions to this external chain.
This is non-recoverable and requires manual intervention and coordination of all validators (observers) to fix the blocking nonce.
Proof of Concept
Observers retry failed outbound transaction broadcasts for a maximum of 5 retries. Subsequently, in the last retry attempt, the for loop is exited in line 579 and the function execution is finished.
_Please note that the HandleBroadcastError function in line 570 only handles certain RPC errors and otherwise, simply instructs a retry._
On the next ticker, the TryProcessOutTx function attempts to send this cctx again but continues to fail. As nonces on the external chain have to be sequential without gaps, any other transactions are blocked from being sent to this external chain.
562:// retry loop: 1s, 2s, 4s, 8s, 16s in case of RPC error
563:for i := 0; i < 5; i++ {
564: logger.Info().Msgf("broadcasting tx %s to chain %s: nonce %d, retry %d", outTxHash, toChain, send.GetCurrentOutTxParam().OutboundTxTssNonce, i)
565: // #nosec G404 randomness is not a security issue here
566: time.Sleep(time.Duration(rand.Intn(1500)) * time.Millisecond) // FIXME: use backoff
567: err := signer.Broadcast(tx)
568: if err != nil {
569: log.Warn().Err(err).Msgf("OutTx Broadcast error")
570: retry, report := HandleBroadcastError(err, strconv.FormatUint(send.GetCurrentOutTxParam().OutboundTxTssNonce, 10), toChain.String(), outTxHash)
571: if report {
572: zetaHash, err := zetaBridge.AddTxHashToOutTxTracker(toChain.ChainId, tx.Nonce(), outTxHash, nil, "", -1)
573: if err != nil {
574: logger.Err(err).Msgf("Unable to add to tracker on ZetaCore: nonce %d chain %s outTxHash %s", send.GetCurrentOutTxParam().OutboundTxTssNonce, toChain, outTxHash)
575: }
576: logger.Info().Msgf("Broadcast to core successful %s", zetaHash)
577: }
578: if !retry {
579: break
580: }
581: backOff *= 2
582: continue
583: }
584: logger.Info().Msgf("Broadcast success: nonce %d to chain %s outTxHash %s", send.GetCurrentOutTxParam().OutboundTxTssNonce, toChain, outTxHash)
585: zetaHash, err := zetaBridge.AddTxHashToOutTxTracker(toChain.ChainId, tx.Nonce(), outTxHash, nil, "", -1)
586: if err != nil {
587: logger.Err(err).Msgf("Unable to add to tracker on ZetaCore: nonce %d chain %s outTxHash %s", send.GetCurrentOutTxParam().OutboundTxTssNonce, toChain, outTxHash)
588: }
589: logger.Info().Msgf("Broadcast to core successful %s", zetaHash)
590: break // successful broadcast; no need to retry
591:}
In regards to the intrinsic gas, a single non-zero byte of transaction data costs 16 gas, plus an additional flat fee of 21_000. For more details on how the intrinsic gas is calculated, see the EVM's IntrinsicGas function.
As the maximum message length is not exceeded, the inbound transaction is sent to ZetaChain, and once it's successfully voted upon, the resulting cctx is put into the pending queue of outgoing transactions.
Given that the provided message and its length is 7680 (please refer to the PoC below for details on how this value is chosen), the intrinsic gas cost is 7680 * 16 = 122880 + flat fee of 21k = ~144k and the transaction must have at least this amount of gas to be accepted by the RPC.
Consequently, the gas limit can be forced to be set to 100k. Attempting to broadcast such a transaction to the EVM RPC will result in an error due to the validation of the intrinsic gas costs of ~144k and the insufficient provided gas of 100k.
Simple PoC
The following Solidity contract demonstrates how such a cross-chain message with the maximum message length of 10240 can be crafted:
contract ZetaChain is ZetaReceiver {
ZetaConnector public connector = ZetaConnector(0x733aB8b06DDDEf27Eaa72294B0d7c9cEF7f12db9);
IERC20 public zeta = IERC20(0xA8D5060feb6B456e886F023709A2795373691E63);
function send(uint256 amount, uint256 gasLimit) external {
uint256 length = 7680;
bytes memory message = new bytes(length);
// fill with non-zero data
for (uint256 i = 0; i < length; i++) {
message[i] = bytes1(uint8(1));
}
ZetaInterfaces.SendInput memory input = ZetaInterfaces.SendInput(
1337,
abi.encodePacked(address(this)),
gasLimit,
message,
amount,
""
);
zeta.transferFrom(msg.sender, address(this), amount);
zeta.approve(address(connector), amount);
connector.send(input);
}
}
Calling this send function on the custom deployed ZetaChain contract on Ethereum, with amount = 4e18 and gasLimit = 100_000, causes the ZetaSent event to be emitted and picked up by the observers.
Once the transaction is sent to ZetaChain and finalized, observers attempt to broadcast the transaction to the receiver chain but fail with the following RPC error:
ERR Broadcast error: nonce 3 chain chain_name:goerli_localnet chain_id:1337 outTxHash 0x749c4e60f0428ba4f558bfca892dca5ba70bc9542e26f1f978240eaf0d5d73fe; retrying... error="intrinsic gas too low"
Tools Used
Manual review
Recommended mitigation steps
Consider properly handling a repeatedly failed RPC call in the TryProcessOutTx function and ensure that such a blocked (pending) nonce can be fixed, i.e., assigned to another cctx that can be broadcasted.
Additionally, consider lowering the maximum message length or adjusting the minimum gas limit to ensure the intrinsic gas costs are covered.
Lines of code
https://github.com/code-423n4/2023-11-zetachain/blob/b237708ed5e86f12c4bddabddfd42f001e81941a/repos/node/zetaclient/evm_signer.go#L579
Vulnerability details
Impact
Outgoing transactions to an external EVM chain can be maliciously blocked by crafting a cctx that can not be broadcasted, i.e., causing the RPC to error (with an error that is not handled in the
HandleBroadcastError
function).For example, causing the intrinsic gas limit to exceed the provided gas limit (minimum
100k
) prevents the transaction from being included in the EVM mempool and blocks the queue of pending outgoing transactions to this external chain.This is non-recoverable and requires manual intervention and coordination of all validators (observers) to fix the blocking nonce.
Proof of Concept
Observers retry failed outbound transaction broadcasts for a maximum of 5 retries. Subsequently, in the last retry attempt, the
for
loop is exited in line579
and the function execution is finished._Please note that the
HandleBroadcastError
function in line570
only handles certain RPC errors and otherwise, simply instructs a retry._On the next ticker, the
TryProcessOutTx
function attempts to send this cctx again but continues to fail. As nonces on the external chain have to be sequential without gaps, any other transactions are blocked from being sent to this external chain.How to exploit
The EVM validates the incoming transaction to exclude transactions with basic errors, such as insufficient intrinsic gas, before being put into the mempool.
In regards to the intrinsic gas, a single non-zero byte of transaction data costs 16 gas, plus an additional flat fee of
21_000
. For more details on how the intrinsic gas is calculated, see the EVM'sIntrinsicGas
function.If the transaction's gas limit is insufficient to cover the intrinsic gas, the transaction will be rejected, and the RPC call will result in an error.
This fact can be exploited, e.g., by sending a Zeta cross-chain message from an external chain A to chain B via the
ZetaConnectorEth.send
function. Specifically, an attacker can provide a message,input.message
, with a length that ultimately exceeds the maximum message limit ofMaxMessageLength = 10240
(this maximum length is enforced in theMsgVoteOnObservedInboundTx
message'sValidateBasic
function and prevents observers from sending such a message to ZetaChain as well as also preventing any further processing of the message in case it reaches ZetaChain).As the maximum message length is not exceeded, the inbound transaction is sent to ZetaChain, and once it's successfully voted upon, the resulting cctx is put into the pending queue of outgoing transactions.
Subsequently, observers will process the cctx and attempt to send the transaction to the external chain B by collectively signing and broadcasting the message to the chain.
Given that the provided message and its length is
7680
(please refer to the PoC below for details on how this value is chosen), the intrinsic gas cost is7680 * 16 = 122880 + flat fee of 21k = ~144k
and the transaction must have at least this amount of gas to be accepted by the RPC.However, sending Zeta messages allows specifying an arbitrary (non-zero) destination gas limit, which is lower-bounded to
100_000
.Consequently, the gas limit can be forced to be set to
100k
. Attempting to broadcast such a transaction to the EVM RPC will result in an error due to the validation of the intrinsic gas costs of~144k
and the insufficient provided gas of100k
.Simple PoC
The following Solidity contract demonstrates how such a cross-chain message with the maximum message length of
10240
can be crafted:Internally, the observer encodes the message to base64, thus increasing the length of the message by roughly a factor of 4/3. Consequently, to not exceed the maximum message length, the message length is set to
10240 * 3/4 = 7680
in Solidity.Calling this
send
function on the custom deployedZetaChain
contract on Ethereum, withamount = 4e18
andgasLimit = 100_000
, causes theZetaSent
event to be emitted and picked up by the observers.Once the transaction is sent to ZetaChain and finalized, observers attempt to broadcast the transaction to the receiver chain but fail with the following RPC error:
Tools Used
Manual review
Recommended mitigation steps
Consider properly handling a repeatedly failed RPC call in the
TryProcessOutTx
function and ensure that such a blocked (pending) nonce can be fixed, i.e., assigned to another cctx that can be broadcasted.Additionally, consider lowering the maximum message length or adjusting the minimum gas limit to ensure the intrinsic gas costs are covered.
Assessed type
Other