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:
- 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).
- 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).
- ZetaChain creates the revert outbound.
PayGasNativeAndUpdateCctx deducts gas: newAmount = G - G = 0. The revert outbound is signed and broadcast.
- Revert outbound fails on the source chain (
Y.onRevert reverts). TSS hot wallet paid the source-chain gas for that failed tx.
processFailedOutboundV2 returns error from the PendingRevert branch (cctx_orchestrator_validate_outbound.go:495-497).
ProcessAbort runs. GetAbortedAmount reads CurrentOutboundParam.Amount == 0 and falls back to InboundParams.Amount == G. fungibleKeeper.ProcessAbort mints G ZRC20 to attacker's Z.
- 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-129 — GetAbortedAmount with the buggy fallback
x/crosschain/keeper/gas_payment.go:100-160 — PayGasNativeAndUpdateCctx; produces newAmount = 0 when outTxGasFee == inputAmount
x/crosschain/keeper/cctx_orchestrator_validate_outbound.go:104-183 — processFailedOutboundOnExternalChain; creates the revert outbound and calls PayGasAndUpdateCctx
x/crosschain/keeper/cctx_orchestrator_validate_outbound.go:226-247 — processFailedOutboundObservers; 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:
-
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.
-
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
Summary
GetAbortedAmountatx/crosschain/keeper/cctx_utils.go:120-129falls back toInboundParams.Amountwhenevercctx.GetCurrentOutboundParam().Amount.IsZero(). The comment-driven assumption is thatAmount == 0means "no outbound was ever created." That assumption breaks under V2 reverts:PayGasNativeAndUpdateCctxlegitimately writesnewAmount = inputAmount - outTxGasFee = 0whenever the depositor pinsinboundAmount == outTxGasFee.When the revert outbound then fails on the source chain (CallOnRevert=true with a RevertAddress whose
onRevertreverts),processFailedOutboundV2returns an error from thePendingRevertbranch, which triggersProcessAbort.ProcessAbortreadsGetAbortedAmount→ gets the full inboundGinstead of0→ feeds it tofungibleKeeper.ProcessAbort→ mintsGZRC20 to the depositor-chosenAbortAddress. 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:GatewayEVM.depositAndCall(receiver=X, amount=G, payload, revertOptions{callOnRevert: true, revertAddress: Y, abortAddress: Z})whereGis sized to exactly the currentoutTxGasFeeof the destination chain (requires racing the gas-price oracle).payloadis crafted so the target call fails on the destination chain (e.g.executeReverton a contract that reverts).PayGasNativeAndUpdateCctxdeducts gas:newAmount = G - G = 0. The revert outbound is signed and broadcast.Y.onRevertreverts). TSS hot wallet paid the source-chain gas for that failed tx.processFailedOutboundV2returns error from thePendingRevertbranch (cctx_orchestrator_validate_outbound.go:495-497).ProcessAbortruns.GetAbortedAmountreadsCurrentOutboundParam.Amount == 0and falls back toInboundParams.Amount == G.fungibleKeeper.ProcessAbortmintsGZRC20 to attacker'sZ.GZRC20 later. The bridge ledger has drifted byG(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-129—GetAbortedAmountwith the buggy fallbackx/crosschain/keeper/gas_payment.go:100-160—PayGasNativeAndUpdateCctx; producesnewAmount = 0whenoutTxGasFee == inputAmountx/crosschain/keeper/cctx_orchestrator_validate_outbound.go:104-183—processFailedOutboundOnExternalChain; creates the revert outbound and callsPayGasAndUpdateCctxx/crosschain/keeper/cctx_orchestrator_validate_outbound.go:226-247—processFailedOutboundObservers; routes V2 failed outbounds toprocessFailedOutboundV2and toProcessAborton errorx/crosschain/keeper/cctx_orchestrator_validate_outbound.go:495-497— thePendingRevertbranch returning an error → triggersProcessAbortSeverity
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
Gis small.Not a duplicate of ZCNode-234 / ZCNode-206 — those cover the legacy
MsgRefundAbortedCCTXpath with asymmetric error handling onErrInsufficientZetaAmount. This is a different code path on the V2ProcessAbortflow forCoinType_Gas/CoinType_ERC20, with a different root cause (stale comment-driven fallback inGetAbortedAmount).Fix
Two-part change, ~20 LOC:
In
GetAbortedAmount, key the fallback offlen(cctx.OutboundParams) > 1(i.e. a revert outbound was created) instead ofCurrentOutboundParam.Amount.IsZero(). If a revert outbound exists, use itsAmountas authoritative — even if zero. TheIsZero()heuristic is what causes the misfire.In
PayGasNativeAndUpdateCctx, tighten the input check frominputAmount > outTxGasFeetoinputAmount >= outTxGasFee + 1(or equivalently reject wheninputAmount == outTxGasFee), so thenewAmount == 0revert-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 = outTxGasFeeand a V2 revert flow, assertsGetAbortedAmountreturns0(or that the revert is rejected upstream entirely).References
hackeproof/analysis/blockchain/report_ZCNode-276.md