Status DataClose notification

ZIGChain Disclosed Report

IBC refund on acknowledgement and timeout can unlock module native token for non wrapped packets

Company
Created date
Jan 04 2026

Target

hidden

Vulnerability Details

Summary

The tokenwrapper IBC middleware decides whether to run refund and tracking logic by extracting only the base denom from packet data. Because the gate checks only the base denom, any IBC denom trace that ends with the configured module denom is treated as eligible, even when the packet denom trace was not produced by the wrapper send path.

When a packet times out, the refund path locks the sender’s IBC voucher (derived from the attacker controlled denom trace) and unlocks native uzig from the module account balance. This enables unauthorized conversion and can repeatedly drain the module’s uzig reserves.

The same base denom only gate exists in the acknowledgement handler, so the same vulnerability class applies to error acknowledgements. The attached PoC demonstrates the timeout path end to end.

This issue is distinct from the previously reported acknowledgement deserialization topic: acknowledgement JSON unmarshalling is correct here, but the denom eligibility check for refund and tracking is too permissive.

Severity

Severity: Critical

Why this is Critical (likelihood and impact)

High likelihood:

  • No privileged role is required.
  • The attacker only needs to hold an IBC voucher whose denom trace base equals the configured moduleDenom while its denom trace is not the wrapper expected denom trace, then trigger a timeout on a packet referencing that denom trace.
  • In permissionless IBC environments, receiving assets over additional channels is commonly feasible, and denom traces are not globally namespaced by default, so base denom collisions are realistic.

High impact:

  • Direct theft of protocol owned funds: native uzig is transferred from the tokenwrapper module account to the attacker.
  • The maximum loss is bounded by the full uzig balance of the tokenwrapper module account, and the drain can be repeated until reserves are exhausted.
  • Loss of module liquidity can break legitimate bridging flows and cause broad user impact due to depleted reserves.

Evidence

The exploit drains native uzig directly from the tokenwrapper module account. Therefore, maximum drain equals the on chain uzig balance held by that module account at the time of exploitation.

Network: zig-test-2 Height: 3858150 Block time: 2026-01-04T05:26:10.929083127Z RPC used: https://public-zigchain-testnet-rpc.numia.xyz Tokenwrapper module account address: zig1hdq87rzf327fwz8rw9rnmchj7qa3uxrpxds2fw Module balance in uzig: 19155057

Evidence command and output:

go run ./cmd/zigchaind status --node https://public-zigchain-testnet-rpc.numia.xyz --output json
{"node_info":{"protocol_version":{"p2p":"8","block":"11","app":"0"},"id":"726bc8d260387cf56ecfad3a6bf6fecd903e18a2","listen_addr":"tcp:0.0.0.0:26656","network":"zig-test-2","version":"0.38.19","channels":"40202122233038606100","moniker":"numia-837","other":{"tx_index":"on","rpc_address":"tcp:0.0.0.0:26657"}},"sync_info":{"latest_block_hash":"F7C693EC32AA29C6184A84AD19DE6BC4D8F16D249DC4D27B39F620AEB1A58C6A","latest_app_hash":"78303BF6021CE7C273E9606E01F769B149EEAD22DF8F1310B915AD64872D58C4","latest_block_height":"3858150","latest_block_time":"2026-01-04T05:26:10.929083127Z","earliest_block_hash":"CBE16BA4663516A417A001BF71B106C20538BA53D6DB26CB10F411248D314D4F","earliest_app_hash":"CEA02B984B8B19E4C2EDDC6DD85F95B21AB00D44A81A441609D703926F2A7558","earliest_block_height":"2990001","earliest_block_time":"2025-11-08T22:41:52.414713743Z","catching_up":false},"validator_info":{"address":"86876BB0F5EE92A368735036EFDC6CDDC2DB8D7A","pub_key":{"type":"tendermint/PubKeyEd25519","value":"Al8dqhfO4gLwtfIRaKulN5TbxRmlPD2kst3xJAxzpdk="},"voting_power":"0"}}
go run ./cmd/zigchaind query bank balance zig1hdq87rzf327fwz8rw9rnmchj7qa3uxrpxds2fw uzig --node https://public-zigchain-testnet-rpc.numia.xyz --height 3858150 --output json
{
  "balance": {
    "denom": "uzig",
    "amount": "19155057"
  }
}

This confirms the module account holds nonzero uzig reserves on chain, so the exploit has immediate theft capacity up to this balance.

CVSS v3.1

Base Score: 9.1 Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H Contextual note: CVSS does not model TVL directly. The criticality here is economic: this flaw enables direct unauthorized movement of protocol owned uzig from the module account, bounded by the module’s reserve balance.

Root Cause

  • Timeout refund eligibility is decided by extracting only the base denom from data.Denom and comparing it to moduleDenom, allowing any denom trace that ends with moduleDenom to pass the gate: L71-L80
  • Acknowledgement tracking eligibility is decided by extracting only the base denom from data.Denom and comparing it to moduleDenom, allowing unintended traces to pass the wrapper only gate: L53-L62
  • The wrapper send path rewrites outgoing packet denom into a specific expected prefixed denom trace, but refund and tracking gates do not enforce that exact expected denom trace: L200-L203
  • Refund execution derives the IBC voucher denom from the provided denom trace and unlocks native uzig from module balance based on that trace, enabling conversion for unintended denom traces: L31-L75
  • The denom validator used by the gates only checks string equality against the configured moduleDenom, which becomes unsafe when combined with base denom extraction: L195-L199

Why this works

  • A legitimate wrapped send modifies packet data denom into the wrapper expected prefixed denom trace derived from module native port, module native channel, and moduleDenom.
  • The refund and tracking gates do not verify that data.Denom equals the wrapper expected denom trace. They verify only the base denom.
  • An attacker controlled denom trace that ends with moduleDenom passes the gate even though it is not wrapper produced.
  • On timeout, handleRefund locks the attacker’s IBC voucher derived from the attacker supplied denom trace and unlocks native uzig from the tokenwrapper module account, transferring protocol owned uzig to the attacker.
  • The acknowledgement handler contains the same base denom only gating pattern, so the same class of issue applies to failed acknowledgements as well. The attached PoC demonstrates the timeout path.

Preconditions

  • The tokenwrapper module is enabled.
  • Tokenwrapper IBC settings are configured such that the timeout path is processed on the configured channel.
  • The tokenwrapper module account holds sufficient native uzig reserves.
  • The attacker holds an IBC voucher on Zigchain whose denom trace base equals moduleDenom but whose denom trace is not the wrapper expected denom trace.
  • The attacker triggers a timeout callback for a packet referencing that denom trace.

Impact

  • Unauthorized conversion of non wrapped IBC vouchers into native uzig.
  • Draining of protocol owned uzig reserves held by the tokenwrapper module account.
  • Loss of module liquidity can cause widespread failures for legitimate bridge users.

Validation steps

Recommendation

Enforce that refund and wrapper only tracking logic runs only when data.Denom matches the exact denom trace produced by the wrapper send path, not merely when the base denom matches moduleDenom.

File: x/tokenwrapper/module/on_timeout_packet.go - Click here File: x/tokenwrapper/module/on_acknowledgment_packet.go - Click here File: x/tokenwrapper/module/send_packet.go - Click here File: x/tokenwrapper/module/handlers.go - Click here

Patch

File: x/tokenwrapper/module/refund_gate.go (Create new file) - Click here

package tokenwrapper

import (
	"fmt"

	sdk "github.com/cosmos/cosmos-sdk/types"
	transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types"
)

func (im IBCModule) validatePacketDenomIsExpectedWrappedSendDenom(ctx sdk.Context, denom string) bool {
	moduleNativePort := im.keeper.GetNativePort(ctx)
	moduleNativeChannel := im.keeper.GetNativeChannel(ctx)
	moduleDenom := im.keeper.GetDenom(ctx)

	sourcePrefix := transfertypes.NewHop(moduleNativePort, moduleNativeChannel)
	expectedPrefixedDenom := fmt.Sprintf("%s/%s", sourcePrefix.String(), moduleDenom)

	return denom == expectedPrefixedDenom
}

File: x/tokenwrapper/module/on_timeout_packet.go - Click here Insert this strict gate where the current base denom gate is used, before refund logic runs:

if !im.validatePacketDenomIsExpectedWrappedSendDenom(ctx, data.Denom) {
	info := fmt.Sprintf("packet denom is not the expected wrapped send denom, skipping refunding: %s", data.Denom)
	types.EmitTokenWrapperInfoEvent(ctx, info)
	im.keeper.Logger().Info(info)
	return im.app.OnTimeoutPacket(ctx, channelVersion, packet, relayer)
}

File: x/tokenwrapper/module/on_acknowledgment_packet.go - Click here Insert this strict gate where the current base denom gate is used, before tracking logic runs:

if !im.validatePacketDenomIsExpectedWrappedSendDenom(ctx, data.Denom) {
	info := fmt.Sprintf("packet denom is not the expected wrapped send denom, skipping tracking: %s", data.Denom)
	types.EmitTokenWrapperInfoEvent(ctx, info)
	im.keeper.Logger().Info(info)
	return im.app.OnAcknowledgementPacket(ctx, channelVersion, packet, acknowledgement, relayer)
}

Proof of Concept

PoC type: integration style test that demonstrates theft of protocol owned uzig by repeatedly converting attacker controlled non wrapped vouchers whose denom trace base collides with moduleDenom.

PoC location (attached in submission): x/tokenwrapper/module/dualdefense_test.go Test name: TestDualDefense_TimeoutRefund

The PoC simulates the attacker already holding the untrusted IBC voucher, matching the post ICS20 receive state where such vouchers exist on chain; the core issue is that refund authorization is based on base denom only.

The PoC is self validating by asserting:

  • The attacker starts with only the attacker controlled IBC voucher balance.
  • The module account starts with a funded uzig reserve.
  • Each iteration reduces module uzig and increases attacker uzig.
  • The loop drains module uzig to zero while moving the attacker supplied vouchers into the module account.

Command to run

go test -v ./x/tokenwrapper/module -run TestDualDefense_TimeoutRefund -count=1

Expected result

  • On vulnerable code: the test passes while showing attacker uzig increasing and module uzig decreasing until drained, demonstrating unauthorized conversion and theft of protocol owned funds.
  • After applying the patch: refund and tracking are skipped unless data.Denom exactly matches the wrapper expected denom trace. As a result, assertions expecting uzig transfer to the attacker will fail. A separate fixed behavior regression test can optionally be added to assert that refunds are skipped for non wrapper denom traces.

Output

┌──(.venv)─(root㉿kali)-[/home/f00dat/Downloads/zigchain-main]
└─# go test -v ./x/tokenwrapper/module -run TestDualDefense_TimeoutRefund -count=1

=== RUN   TestDualDefense_TimeoutRefund
    dualdefense_test.go:132: Setup:
    dualdefense_test.go:133: - moduleDenom: unit-zig
    dualdefense_test.go:134: - expected wrapped denom trace: transfer/channel-12/unit-zig
    dualdefense_test.go:135: - untrusted denom trace: transfer/channel-999/unit-zig
    dualdefense_test.go:136: - expected wrapped voucher denom: ibc/37FC4E4C1B689033F031E7973F148DEAAAFA7A588119099568A40675724F5E28
    dualdefense_test.go:137: - untrusted voucher denom: ibc/CCB6334404FDA13242A9CE56E47F76FAC5523D07BA0AF8706BE5BDFF2E7F0DD9
    dualdefense_test.go:138: - amount per iteration (voucher units): 5000000000000000000000
    dualdefense_test.go:139: - drain iterations: 3
    dualdefense_test.go:125: Initial balances:
    dualdefense_test.go:126: - attacker uzig: 0
    dualdefense_test.go:127: - attacker voucher: 0
    dualdefense_test.go:128: - module uzig: 0
    dualdefense_test.go:129: - module voucher: 0
    dualdefense_test.go:143: Step 1: Simulate attacker holding enough untrusted vouchers for repeated drains
    dualdefense_test.go:125: After seeding attacker voucher balances:
    dualdefense_test.go:126: - attacker uzig: 0
    dualdefense_test.go:127: - attacker voucher: 15000000000000000000000
    dualdefense_test.go:128: - module uzig: 0
    dualdefense_test.go:129: - module voucher: 0
    dualdefense_test.go:151: Step 2: Call OnRecvPacket on non wrapped channel and assert it does not revert attacker voucher balance
    dualdefense_test.go:125: After OnRecvPacket (non wrapped channel) balances:
    dualdefense_test.go:126: - attacker uzig: 0
    dualdefense_test.go:127: - attacker voucher: 15000000000000000000000
    dualdefense_test.go:128: - module uzig: 0
    dualdefense_test.go:129: - module voucher: 0
    dualdefense_test.go:181: Step 3: Fund module account with enough uzig reserves to be fully drained
    dualdefense_test.go:187: - expected unlocked uzig per iteration: 5000000000
    dualdefense_test.go:188: - total module uzig funded: 15000000000
    dualdefense_test.go:125: After funding module reserves balances:
    dualdefense_test.go:126: - attacker uzig: 0
    dualdefense_test.go:127: - attacker voucher: 15000000000000000000000
    dualdefense_test.go:128: - module uzig: 15000000000
    dualdefense_test.go:129: - module voucher: 0
    dualdefense_test.go:203: Step 4: Loop timeout refund until module uzig is drained
    dualdefense_test.go:236: Iteration 1 result:
    dualdefense_test.go:237: - attacker uzig delta: 5000000000
    dualdefense_test.go:238: - module uzig delta: -5000000000
    dualdefense_test.go:239: - attacker voucher delta: -5000000000000000000000
    dualdefense_test.go:240: - module voucher delta: 5000000000000000000000
    dualdefense_test.go:236: Iteration 2 result:
    dualdefense_test.go:237: - attacker uzig delta: 5000000000
    dualdefense_test.go:238: - module uzig delta: -5000000000
    dualdefense_test.go:239: - attacker voucher delta: -5000000000000000000000
    dualdefense_test.go:240: - module voucher delta: 5000000000000000000000
    dualdefense_test.go:236: Iteration 3 result:
    dualdefense_test.go:237: - attacker uzig delta: 5000000000
    dualdefense_test.go:238: - module uzig delta: -5000000000
    dualdefense_test.go:239: - attacker voucher delta: -5000000000000000000000
    dualdefense_test.go:240: - module voucher delta: 5000000000000000000000
    dualdefense_test.go:125: Final balances:
    dualdefense_test.go:126: - attacker uzig: 15000000000
    dualdefense_test.go:127: - attacker voucher: 0
    dualdefense_test.go:128: - module uzig: 0
    dualdefense_test.go:129: - module voucher: 15000000000000000000000
--- PASS: TestDualDefense_TimeoutRefund (0.01s)
PASS
ok      zigchain/x/tokenwrapper/module  0.183s

Additional notes for triage

  • The attached PoC demonstrates the timeout path. The acknowledgement handler contains the same base denom only gating pattern and should be treated as the same vulnerability class for error acknowledgements.
  • Maximum drain equals the tokenwrapper module account uzig balance, and evidence above shows that the module account held 19155057 uzig at height 3858150 on zig-test-2.

Attachments

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