ZRC-20 paused status can be bypassed
Description
The MsgUpdateZRC20PausedStatus
message is used to pause a ZRC-20 contract on the zEVM. Once a contract is paused, any transaction that calls the contract and generates an event (such as a Transfer
or Approval
event) will cause the transaction to revert instead.
This message is intended to be used if a ZRC-20 contract is hacked or funds are stolen in some other way. In such a scenario, a paused status would prevent the attacker from transferring stolen funds across chains.
The CheckPausedZRC20()
function is used to determine whether a transaction contains logs from a paused ZRC-20 contract. This is located in x/fungible/keeper/evm_hooks.go:
// PostTxProcessing is a wrapper for calling the EVM PostTxProcessing hook on the module keeper
func (h EVMHooks) PostTxProcessing(ctx sdk.Context, _ core.Message, receipt *ethtypes.Receipt) error {
return h.k.CheckPausedZRC20(ctx, receipt)
}
// CheckPausedZRC20 checks the events of the receipt
// if an event is emitted from a paused ZRC20 contract it will revert the transaction
func (k Keeper) CheckPausedZRC20(ctx sdk.Context, receipt *ethtypes.Receipt) error {
// [ ... ]
// get non-duplicated list of all addresses that emitted logs
// [ ... ]
// check if any of the addresses are from a paused ZRC20 contract
for _, address := range addresses {
fc, found := k.GetForeignCoins(ctx, address.Hex())
if found && fc.Paused {
return cosmoserrors.Wrap(types.ErrPausedZRC20, address.Hex())
}
}
return nil
}
This function is called during the PostTxProcessing()
function, which is a hook introduced in Ethermint. The Evmos docs↗ explain how this works:
PostTxProcessing is only called after an EVM transaction finished successfully and delegates the call to underlying hooks. If no hook has been registered, this function returns with a nil error.
These docs, however, are unfortunately quite misleading. The PostTxProcessing()
function is actually not called after an EVM transaction finishes successfully. It is only called when the underlying Ethermint code calls the ApplyTransaction()
function. This is evident from a comment in the official Evmos ERC-20 module:
// Note that the PostTxProcessing hook is only called by sending an EVM
// transaction that triggers `ApplyTransaction`. A cosmos tx with a
// `ConvertERC20` msg does not trigger the hook as it only calls `ApplyMessage`.
func (k Keeper) PostTxProcessing(
ctx sdk.Context,
_ core.Message,
receipt *ethtypes.Receipt,
) error { /* ... */ }
Both the ApplyTransaction()
and ApplyMessage()
functions will commit any state changes to the blockchain. However, only ApplyTransaction()
will trigger the PostTxProcessing()
hook.
Impact
Ethermint exposes a CallEVM()
function that allows a Cosmos chain to call a smart contract within the Ethermint EVM. This code was ported into the ZetaChain repo, and it is evident that this function uses ApplyMessage()
instead of ApplyTransaction()
to commit state changes:
func (k Keeper) CallEVM(
// [ ... ]
) (*evmtypes.MsgEthereumTxResponse, error) {
// [ ... ]
resp, err := k.CallEVMWithData(ctx, from, &contract, data, commit, noEthereumTxEvent, value, gasLimit)
if err != nil {
return resp, cosmoserrors.Wrapf(err, "contract call failed: method '%s', contract '%s'", method, contract)
}
return resp, nil
}
func (k Keeper) CallEVMWithData(
// [ ... ]
) (*evmtypes.MsgEthereumTxResponse, error) {
// [ ... ]
res, err := k.evmKeeper.ApplyMessage(ctx, msg, evmtypes.NewNoOpTracer(), commit)
if err != nil {
return nil, err
}
// [ ... ]
}
Normally, this is okay, as CallEVM()
is intended to be used to initiate privileged smart contract calls. However, ZetaChain actually has a code path that uses CallEVM()
to call a user-defined smart contract. This code path is triggered when the user takes the following steps:
User triggers an ERC-20 deposit from an EVM chain to the zEVM. This is done using the deployed ERC20Custody contract's
deposit()
function on the EVM.The observers pick up the
Deposited
event and then initiate a call on ZetaChain to deposit the tokens.If the address being deposited into is detected as a contract account, the contract's
onCrossChainCall()
function is called.
This onCrossChainCall()
code path is reached through the following functions:
// Observers call VoteOnObservedInboundTx()
VoteOnObservedInboundTx() -> HandleEVMDeposit() -> ZRC20DepositAndCallContract()
func (k Keeper) ZRC20DepositAndCallContract(
// [ ... ]
) (*evmtypes.MsgEthereumTxResponse, bool, error) {
// [ ... ]
// check if the receiver is a contract
// if it is, then the hook onCrossChainCall() will be called
// if not, the zrc20 are simply transferred to the receiver
acc := k.evmKeeper.GetAccount(ctx, to)
if acc != nil && acc.IsContract() {
context := systemcontract.ZContext{
Origin: from,
Sender: eth.Address{},
ChainID: big.NewInt(senderChain.ChainId),
}
res, err := k.DepositZRC20AndCallContract(ctx, context, ZRC20Contract, to, amount, data)
return res, true, err
}
// [ ... ]
}
func (k Keeper) DepositZRC20AndCallContract(
// [ ... ]
) (*evmtypes.MsgEthereumTxResponse, error) {
// [ ... ]
return k.CallEVM( /* ... */ )
}
As seen above, when ZetaChain calls the onCrossChainCall()
function, it uses CallEVM()
, meaning the PostTxProcessing()
hooks will not be triggered.
The ZetaChain developers are aware of this because they manually make a call to the ProcessLogs()
function, which is also called through the x/crosschain module's PostTxProcessing()
hook to handle any events emitted by the ConnectorZEVM contract. These events are used to process cross-chain transfers:
func (k Keeper) HandleEVMDeposit( /* ... */ ) (bool, error) {
// [ ... ]
if msg.CoinType == common.CoinType_Zeta {
// [ ... ]
} else {
// [ ... ]
evmTxResponse, contractCall, err := k.fungibleKeeper.ZRC20DepositAndCallContract( /* ... */ )
if err != nil {
// [ ... ]
}
// non-empty msg.Message means this is a contract call; therefore the logs should be processed.
// a withdrawal event in the logs could generate cctxs for outbound transactions.
if !evmTxResponse.Failed() && contractCall {
logs := evmtypes.LogsToEthereum(evmTxResponse.Logs)
if len(logs) > 0 {
// [ ... ]
err = k.ProcessLogs(ctx, logs, to, txOrigin)
// [ ... ]
}
}
}
return false, nil
}
However, since CheckPausedZRC20()
is not called here, this allows an attacker to transfer paused ZRC-20 tokens out of the zEVM by triggering a zero-amount token deposit from a supported EVM chain.
Recommendations
We recommend patching CallEVMWithData()
to call the PostTxProcessing()
hooks, similar to how the Teleport Network did here↗.
Remediation
ZetaChain implemented a fix for this issue in Pull Request #31↗ by adding logic in the cosmos message handler that checks if the coin is paused. However, it is important to note that this check should be replicated for every instance of CallEvm
and similar methods. We also recommend that this behavior be documented for future engineers when developing new handlers.