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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions