Summary

On April 26, 2026, an exploiter compromised the arbitrary call functionality in ZetaChain's GatewayEVM contract to drain ERC-20 tokens from the ZetaChain team’s wallets that had previously granted token allowances to the gateway. The vulnerability allowed the exploiter to instruct the gateway to execute transferFrom calls on ERC-20 token contracts, spending victims' pre-existing allowances.

Cross-chain transactions were paused quickly after detection. A zetaclient patch was developed, tested, and deployed to Testnet the same day. The mainnet patch is being rolled out to all operator nodes now.

No user funds were lost. All three affected wallets were ZetaChain-controlled. Total losses were approximately $333,868 across 9 drain transactions on 4 chains.


Root Cause

The exploit chained three design properties in the cross-chain messaging pipeline:

1. Unrestricted Arbitrary Calls

The GatewayZEVM.call() function accepted a CallOptions.isArbitraryCall = true flag from any caller. When set, the ZetaClient observer software zeroed out the message sender in the outbound transaction:

// GatewayZEVM.sol - anyone could request an arbitrary call
function call(
    bytes memory receiver,
    address zrc20,
    bytes calldata message,
    CallOptions calldata callOptions, // isArbitraryCall: true allowed
    RevertOptions calldata revertOptions
) external whenNotPaused { ... }
// outbound_data.go - ZetaClient zeroes the sender
if o.callOptions.IsArbitraryCall {
    messageContext.Sender = ethcommon.Address{} // address(0)
}

2. Insufficient Function Selector Deny-List

On the destination chain, GatewayEVM.execute() routed calls with sender == address(0) to _executeArbitraryCall(), which performed a raw external call on any destination address with any calldata. The only protection was a selector deny-list that blocked onCall and onRevert selectors:

function _executeArbitraryCall(address destination, bytes calldata data)
    private returns (bytes memory)
{
    _revertIfOnCallOrOnRevert(data); // ONLY blocks onCall/onRevert
    (bool success, bytes memory result) = destination.call{value: msg.value}(data);
    if (!success) revert ExecutionFailed();
    return result;
}

Critical ERC-20 selectors (transferFrom, approve, transfer, permit) were not in the deny-list.

3. Stale ERC-20 Allowances

Users who had previously deposited tokens via GatewayEVM.deposit() had granted ERC-20 allowances to the gateway contract. Many of these allowances were set to unlimited and were never revoked after the deposit completed.

Exploit Flow

flowchart LR
    A["Hacker calls<br>GatewayZEVM.call()<br>isArbitraryCall=true"] --> B["ZetaClient signs<br>outbound with<br>sender=address(0)"]
    B --> C["GatewayEVM.execute()<br>routes to<br>_executeArbitraryCall()"]
    C --> D["Raw .call() on<br>USDC/USDT contract<br>with transferFrom()"]
    D --> E["Gateway spends<br>victim's allowance<br>Tokens sent to hacker"]

The exploiter set destination = <ERC-20 token contract> and data = transferFrom(victim, hacker, amount). Since the gateway was msg.sender and held the victim's allowance, the token contract executed the transfer.