Status DataClose notification

ZIGChain Disclosed Report

Unprivileged users can drain native uzig from the tokenwrapper module on IBC timeouts

Company
Created date
Jan 05 2026

Target

hidden

Vulnerability Details

The tokenwrapper refund logic for outgoing ICS-20 transfers is incorrectly gated by the base denom of the packet’s data.Denom. If an outgoing packet’s denom trace ends with /uzig, the callbacks treat it as the module’s wrapped asset and run handleRefund(), which unlocks native uzig from the module account to the sender.

To reproduce (use the attached PoC test):

  1. Ensure tokenwrapper is configured and enabled (SetEnabled(true)), with module denom set to uzig (SetDenom("uzig")), and the packet uses the configured native/counterparty port+channel (so validateIBCSettingsMatchOnSendPacket() passes).
  2. Construct an outgoing packet whose data.Denom is a foreign denom trace that ends in /uzig, e.g. transfer/channel-999/uzig (this is the denom-base collision).
  3. Ensure the sender holds the corresponding IBC voucher on Zigchain: ibcDenom := transfertypes.ExtractDenomFromPath("transfer/channel-999/uzig").IBCDenom().
  4. Ensure the tokenwrapper module account holds some native uzig balance.
  5. Trigger the refund path by simulating an outgoing failure (timeout or failed acknowledgement). On timeout, call IBCModule.OnTimeoutPacket().
  6. Observe that the sender receives native uzig from the module, while the module receives/locks the sender’s IBC voucher.

Validation steps

The x/tokenwrapper module implements IBC middleware around ICS-20 transfers. In the normal “wrapped ZIG” flow, the middleware mutates outgoing packet fields in ICS4Wrapper.SendPacket() and later relies on the IBC callbacks IBCModule.OnTimeoutPacket() and IBCModule.OnAcknowledgementPacket() to execute a refund path (handleRefund()) when the outgoing transfer fails (timeout or failed acknowledgement).

The refund gating in IBCModule.OnTimeoutPacket() (and similarly in IBCModule.OnAcknowledgementPacket()) decides whether to refund by extracting and comparing only the base denom from the packet’s data.Denom:

	// Get base denom from the packet data
	baseDenom := transfertypes.ExtractDenomFromPath(data.Denom).Base

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

validateIBCDenomIsModuleDenom() only checks denom == keeper.GetDenom(ctx). This is a weak invariant: ExtractDenomFromPath(...).Base is just the last path component, so any denom trace ending in "/uzig" will pass this check when keeper.GetDenom(ctx) is "uzig".

Once this base-denom check passes, handleRefund() derives an IBC voucher denom from the full denom trace and then unlocks native constants.BondDenom ("uzig") from the module account to the sender:

ibcDenom := transfertypes.ExtractDenomFromPath(denom).IBCDenom()
...
im.keeper.LockTokens(ctx, sender, sdk.NewCoins(sdk.NewCoin(ibcDenom, amount)))
...
im.keeper.UnlockTokens(ctx, sender, sdk.NewCoins(sdk.NewCoin(constants.BondDenom, convertedAmount)))

This creates a swap-like behavior on failure: if a sender can arrange for an outgoing packet whose data.Denom has base "uzig" (e.g., transfer/channel-999/uzig), then on timeout/ack-failure the module will accept whatever IBC voucher corresponds to that denom trace and pay out real native "uzig" from the module’s reserves.

[!NOTE] This does not require the packet to have been produced by ICS4Wrapper.SendPacket()’s wrapping path; it only requires that the timeout/ack-failure callback is invoked on the configured channel and the packet denom’s base equals the configured module denom.

Highest-impact scenario: the attacker acquires IBC vouchers on Zigchain whose denom trace ends with "/uzig" (a foreign asset coincidentally named uzig), sends them out via the configured tokenwrapper channel, forces a timeout (or failed acknowledgement), and receives native "uzig" from the tokenwrapper module account. This can be repeated to drain all native "uzig" held by the module.

Impact

Critical, An unprivileged attacker can convert attacker-controlled/low-value IBC vouchers into real native uzig and drain the tokenwrapper module’s uzig balance on timeout/ack-failure.

Attachments

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