Status DataClose notification

ZIGChain Disclosed Report

`tokenwrapper` refund conversion lets attackers drain native `uzig` from the module wallet using foreign `unit-zig` IBC vouchers ( on ack error/timeout )

Company
Created date
Jan 08 2026

Target

hidden

Vulnerability Details

x/tokenwrapper performs a refund conversion on failed outgoing IBC transfers - on ack error or timeout. It locks the refunded IBC boucher and unlocks native uzig from the TokenWrapper module account to the sender.

The eligibility check only compares the base denom unit-zig and does not validate the voucher's denom trace ie. it doesn't verify whether it's the real Axelar-originated unit-zig voucher vs any other chain's .../unit-zig.

The root cause is located in the following logic:

// x/tokenwrapper/module/on_acknowledgment_packet.go:54-62
baseDenom := transfertypes.ExtractDenomFromPath(data.Denom).Base
if !im.validateIBCDenomIsModuleDenom(ctx, baseDenom) {
    return im.app.OnAcknowledgementPacket(...)
}

where validateIBCDenomIsModuleDenom() contains the insufficient check - only the plain denom string is verified:

// x/tokenwrapper/module/validators.go:195-199
func (im IBCModule) validateIBCDenomIsModuleDenom(ctx sdk.Context, denom string) bool {
    moduleDenom := im.keeper.GetDenom(ctx)
@>  return denom == moduleDenom
}

On ack failure, it converts the refund into uzig paid from the module wallet:

// x/tokenwrapper/module/on_acknowledgment_packet.go:117-126
if !ack.Success() {
    if err := im.handleRefund(ctx, sender, amount, data.Denom); err != nil {
        return nil
    }
    return nil
}

Refund conversion usees the refunded denom trace, computes the IBC denom and then locks vouchers + unlocks uzig:

// x/tokenwrapper/module/handlers.go:35-75
ibcDenom := transfertypes.ExtractDenomFromPath(denom).IBCDenom()
ibcCoins := sdk.NewCoins(sdk.NewCoin(ibcDenom, amount))

convertedAmount, _ := im.keeper.ScaleDownTokenPrecision(ctx, amount)
nativeCoins := sdk.NewCoins(sdk.NewCoin(constants.BondDenom, convertedAmount))

_ = im.keeper.LockTokens(ctx, sender, ibcCoins)
_ = im.keeper.UnlockTokens(ctx, sender, nativeCoins)

The same denom check exists on timeouts:

  // x/tokenwrapper/module/on_timeout_packet.go:71-80
	// Get base denom from the packet data
	baseDenom := transfertypes.ExtractDenomFromPath(data.Denom).Base

	// Skip refunding if packet denom is not the module denom
	if !im.validateIBCDenomIsModuleDenom(ctx, baseDenom) {
		info := fmt.Sprintf("packet denom is not the module denom, skipping refunding: %s", baseDenom)
		types.EmitTokenWrapperInfoEvent(ctx, info)
		im.keeper.Logger().Info(info)
		return im.app.OnTimeoutPacket(ctx, channelVersion, packet, relayer)
	}

and the refund call:

  // x/tokenwrapper/module/on_timeout_packet.go:105-111
	// Handle refund for timeout
	if err := im.handleRefund(ctx, sender, amount, data.Denom); err != nil {
		types.EmitTokenWrapperErrorEvent(ctx, err)
		im.keeper.Logger().Error(fmt.Sprintf("failed to handle refund: %v", err))
		// return nil to apply the default behavior of the underlying OnTimeoutPacket
		return nil
	}

The vulnerability can be exploited as follows, by a permissionless attacker:

  1. Attacker runs a dummy Cosmos chain with full control over it. The native denom of that chain is unit-zig.
  2. They oopen an IBC channel to Zigchain, and IBC-transfer unit-zig to Zigchain. Attacker receives an IBC voucher on Zigchain whose denom trace ends with /unit-zig but is not Axelar-originated.
  3. From Zigchain, send that voucher over Zigchain's Axelar channel, but force an ack error ( e.g. invalid receiver ) or a timeout.
  4. On Zigchain, OnAcknowledgementPacket / OnTimeoutPacket sees Base == unit-zig and calls handleRefund(), which pays the attacker native uzig out of the TokenWrapper module wallet while locking the attacker's foreign voucher.

Funds in that module's wallet come from either users bridging out ( native -> Axelar has to LockTokens()):

// x/tokenwrapper/module/send_packet.go
_ = w.keeper.LockTokens(ctx, sender, nativeCoins) // moves uzig into module

or operator top-ups via MsgFundModuleWallet:

// x/tokenwrapper/keeper/msg_server_fund_module_wallet.go
if signer.String() != currentOperator { return ..., "only the current operator..." }
_ = k.LockTokens(ctx, signer, msg.Amount) // SendCoinsFromAccountToModule(..., tokenwrapper)

Attacker can drain the entire x/tokenwrapper module account's native uzig balance by repeatedly trigerring the refund path to swap worthless foreign .../unit-zig vouchers into real uzig, simultaneously causing bridge operational failure until the module is refilled and vulnerability fixed.

Validation steps

Two PoCs were created:

  1. A unit test with mocked IBC setup ( channel, connection keepers and underlying app.OnAcknowledgmentPacket )
  2. e2e test that spins up the Zigchain, Axelar ( Ignite scaffloded ), Dummy ( Ignite scaffolded as well - used as the attacker-controller chain ) and Hermes relayers.

The following explains setup that adds both PoCs to the codebase, for simplicity.

First, make sure you're on the right commit and reset the original repo state:

git checkout b4a40c780a86b9e77e1fd30448d6f8834009c495
git reset --hard
git clean -fd

Download the tokenwrapper_refund_drain_poc.txt file attached to the submission to the zigchain root directory and from that directory run:

git apply ./tokenwrapper_refund_drain_poc.txt

it does the following:

  • adds an in-process GO PoC test ( refund_drain_regression_test.go ) that reproduces the bug; it's only for the unit test ran with go test ...
  • adds an optional localnet PoC harness with short docs (README file generated by an LLM for more clarity if needed)
  • registers a localnet-only test TW-021 which is skipped by default in the shell test runner run_tests.sh
  • makes a localnet setup use an isolated temporary HOME so it won't touch the real ~/.hermes or ~/.ignote chain dirs and cleans relayer state only under that temp home.
  • adds the local caches/temps to .gitignore just to avoid pushing them if the test ends up being used in the future

Unit test

The test shows how:

  • A refunded IBC voucher whose denom path ends in the configured tokenwrapper denom (e.g. .../unit-zig) gets converted.
  • uzig is paid out from the tokenwrapper module account to the sender.
  • The module’s uzig reserves decrease accordingly.

Run the PoC with the following command from the repo root:

mkdir -p .gocache
GOCACHE="$PWD/.gocache" go test ./x/tokenwrapper/module -run TestOnAcknowledgementPacket_RefundConversionDrainsModule_OnForeignIBCVoucher -count=1 -v

e2e test

We spin up 4 local chains and 3 relayers in the background, writing the logs under a temporary WORK_DIR.

Chains:

  • Zigchain: tokenwrapper is enabled and configured in the config.yml file
  • Axelar: Ignite-scaffolded chain started as axelard
  • Dummy ( attacker-controlled chain) : Ignite-scaffolded chain started as dummyd
  • Cosmos ( extra scaffold ): Ignite-scaffolded chain started as cosmosd, technically not necessary for TWO-021, but harness starts it for other tests.

Relayers:

  • dummy <-> zigchain
  • axelar <-> zigchain
  • cosmos <-> zigchain

The TW-021 PoC actually uses:

  • Dummy -> Zigchain IBC transfer to mint foreign unit-zig vouchers on Zigchain
  • Zigchain -> Axelar transfer with an invalid receiver to force an error acknowledgment ( we deliberately set the receiver to a string that Axelar's transfer module can't parse as an address )
  • Hermes relays the packet + ack, Zigchain's tokenwrapper processes the failed ack and performs the buggy refund convertion

To run the PoC, after following the git apply instructions from the beginning of this Validation steps section, use the following command in the project root directory to spin-up all the chains and relayers:

bash poc/tokenwrapper-refund-conversion-drain/e2e_localnet.sh

Then, in a new terminal window run the TW-021 test:

ENV_FILE="$(ls -t ${TMPDIR:-/tmp}/tmp.*/test_env.sh(N) | head -n 1)"
source "$ENV_FILE"
./x/tokenwrapper/sh/run_tests.sh TW-021

To stop all the background services:

pkill hermes zigchaind axelard dummyd cosmosd || true

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Critical
Bounty$443
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden