Skip to content

GetAbortedAmount fallback mints unbacked ZRC20 when V2 revert outbound has zero amount #4606

Description

@kingpinXD

Summary

GetAbortedAmount at x/crosschain/keeper/cctx_utils.go:120-129 falls back to InboundParams.Amount whenever cctx.GetCurrentOutboundParam().Amount.IsZero(). The comment-driven assumption is that Amount == 0 means "no outbound was ever created." That assumption breaks under V2 reverts: PayGasNativeAndUpdateCctx legitimately writes newAmount = inputAmount - outTxGasFee = 0 whenever the depositor pins inboundAmount == outTxGasFee.

When the revert outbound then fails on the source chain (CallOnRevert=true with a RevertAddress whose onRevert reverts), processFailedOutboundV2 returns an error from the PendingRevert branch, which triggers ProcessAbort. ProcessAbort reads GetAbortedAmount → gets the full inbound G instead of 0 → feeds it to fungibleKeeper.ProcessAbort → mints G ZRC20 to the depositor-chosen AbortAddress. The matching source-chain reserve was already drained by the TSS hot wallet broadcasting the failed revert outbound, so the mint is unbacked.

Exploit path

Permissionless via GatewayEVM.depositAndCall:

  1. Attacker calls GatewayEVM.depositAndCall(receiver=X, amount=G, payload, revertOptions{callOnRevert: true, revertAddress: Y, abortAddress: Z}) where G is sized to exactly the current outTxGasFee of the destination chain (requires racing the gas-price oracle).
  2. ZetaChain creates the inbound CCTX. payload is crafted so the target call fails on the destination chain (e.g. executeRevert on a contract that reverts).
  3. ZetaChain creates the revert outbound. PayGasNativeAndUpdateCctx deducts gas: newAmount = G - G = 0. The revert outbound is signed and broadcast.
  4. Revert outbound fails on the source chain (Y.onRevert reverts). TSS hot wallet paid the source-chain gas for that failed tx.
  5. processFailedOutboundV2 returns error from the PendingRevert branch (cctx_orchestrator_validate_outbound.go:495-497).
  6. ProcessAbort runs. GetAbortedAmount reads CurrentOutboundParam.Amount == 0 and falls back to InboundParams.Amount == G. fungibleKeeper.ProcessAbort mints G ZRC20 to attacker's Z.
  7. Attacker withdraws G ZRC20 later. The bridge ledger has drifted by G (the gas portion the attacker paid but kept as ZRC20).

The attacker is approximately net-zero per cycle (their deposit comes back as ZRC20 they can withdraw), but the protocol absorbs the gas-burn gap. Cumulative drift is paid by honest depositors when the attacker eventually unwinds.

Code links

  • x/crosschain/keeper/cctx_utils.go:116-129GetAbortedAmount with the buggy fallback
  • x/crosschain/keeper/gas_payment.go:100-160PayGasNativeAndUpdateCctx; produces newAmount = 0 when outTxGasFee == inputAmount
  • x/crosschain/keeper/cctx_orchestrator_validate_outbound.go:104-183processFailedOutboundOnExternalChain; creates the revert outbound and calls PayGasAndUpdateCctx
  • x/crosschain/keeper/cctx_orchestrator_validate_outbound.go:226-247processFailedOutboundObservers; routes V2 failed outbounds to processFailedOutboundV2 and to ProcessAbort on error
  • x/crosschain/keeper/cctx_orchestrator_validate_outbound.go:495-497 — the PendingRevert branch returning an error → triggers ProcessAbort

Severity

Medium. Permissionless, real per-attempt ledger gap that compounds with each successful attempt, but no direct extraction primitive — attacker is net-zero per cycle and the protocol absorbs the cumulative gas-burn gap as unbacked ZRC20 liability. Sustained-campaign threat shape, not single-shot. Reporter rated High; landing at Medium because the attacker doesn't profit and per-attempt G is small.

Not a duplicate of ZCNode-234 / ZCNode-206 — those cover the legacy MsgRefundAbortedCCTX path with asymmetric error handling on ErrInsufficientZetaAmount. This is a different code path on the V2 ProcessAbort flow for CoinType_Gas / CoinType_ERC20, with a different root cause (stale comment-driven fallback in GetAbortedAmount).

Fix

Two-part change, ~20 LOC:

  1. In GetAbortedAmount, key the fallback off len(cctx.OutboundParams) > 1 (i.e. a revert outbound was created) instead of CurrentOutboundParam.Amount.IsZero(). If a revert outbound exists, use its Amount as authoritative — even if zero. The IsZero() heuristic is what causes the misfire.

  2. In PayGasNativeAndUpdateCctx, tighten the input check from inputAmount > outTxGasFee to inputAmount >= outTxGasFee + 1 (or equivalently reject when inputAmount == outTxGasFee), so the newAmount == 0 revert-outbound case is rejected at the source. A zero-value revert outbound has no economic meaning anyway — the gas would still cost the protocol.

Add a unit test that constructs a CCTX with InboundParams.Amount = outTxGasFee and a V2 revert flow, asserts GetAbortedAmount returns 0 (or that the revert is rejected upstream entirely).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions