DEV Community

Daniel
Daniel

Posted on

How a Base-Denom Collision Let Untrusted IBC Vouchers Drain ZIGChain's Native Reserves

IBC denom traces are part of an asset's identity.

They record the route through which a token entered a chain. Two vouchers may end with the same base denom while representing different assets with different provenance:

transfer/channel-12/unit-zig
transfer/channel-999/unit-zig
Enter fullscreen mode Exit fullscreen mode

Both traces end in unit-zig.

They are still not interchangeable.

ZIGChain's tokenwrapper middleware lost that distinction in its timeout and error-acknowledgement gates. The code parsed the packet denom, extracted only its base denom, and compared that suffix with the configured moduleDenom.

Any valid IBC voucher whose trace ended in unit-zig could therefore be treated as though it had been produced by the wrapper's authorized route.

Once that check passed, the timeout refund path locked the attacker's voucher and released protocol-owned native uzig from the tokenwrapper module account.

The attacker could repeat the conversion until the module's native reserve was exhausted.

The finding was confirmed as Critical in the ZIGChain Code DualDefense Audit and received a $443 reward. It was identified after a professional audit by Oak Security.

The disclosed report is available on HackenProof.

The missing part of the asset identity

An IBC voucher is represented on chain by a denom such as:

ibc/CCB6334404FDA13242A9CE56E47F76FAC5523D07BA0AF8706BE5BDFF2E7F0DD9
Enter fullscreen mode Exit fullscreen mode

That identifier is derived from a denom trace containing routing information:

port/channel/base-denom
Enter fullscreen mode Exit fullscreen mode

The proof used two traces:

Expected wrapper trace
transfer/channel-12/unit-zig

Untrusted trace
transfer/channel-999/unit-zig
Enter fullscreen mode Exit fullscreen mode

They shared the same base denom:

unit-zig
Enter fullscreen mode Exit fullscreen mode

But they produced different IBC voucher hashes because their channel paths were different.

That path difference was the security boundary.

A voucher created through channel-999 should not have been accepted as though it came from the wrapper's configured channel-12 route.

The vulnerable code discarded that distinction before deciding whether wrapper-specific refund logic should run.

What the wrapper expected

The legitimate wrapper send path constructed one exact prefixed denom from:

  1. The configured native port

  2. The configured native channel

  3. The configured module denom

Conceptually:

expected denom =
    native port
    / native channel
    / module denom
Enter fullscreen mode Exit fullscreen mode

Using the proof configuration:

transfer/channel-12/unit-zig
Enter fullscreen mode Exit fullscreen mode

The correct authorization question was:

Does this packet denom exactly match the trace produced by the configured wrapper route?

The vulnerable code asked only:

Does this packet denom end with unit-zig?

Those checks are not equivalent.

The vulnerable timeout gate

The timeout handler parsed data.Denom, extracted the base denom, and compared only that value with moduleDenom.

The logic can be reduced to:

trace := parseDenomTrace(data.Denom)
baseDenom := trace.BaseDenom

if baseDenom != moduleDenom {
    return underlyingApp.OnTimeoutPacket(...)
}

return handleRefund(...)
Enter fullscreen mode Exit fullscreen mode

The full path was no longer part of the authorization decision.

Both of these traces therefore passed the same gate:

transfer/channel-12/unit-zig
transfer/channel-999/unit-zig
Enter fullscreen mode Exit fullscreen mode

The first represented the expected wrapper route.

The second represented a distinct voucher from another channel.

The base-denom-only comparison could not tell them apart.

Why the refund became an unauthorized conversion

After the gate accepted the packet, handleRefund used the full attacker-supplied trace to derive the voucher denom that would be locked.

This created a dangerous mismatch:

Refund eligibility
Decided from the base denom only

Voucher selected for locking
Derived from the full denom trace
Enter fullscreen mode Exit fullscreen mode

The handler then performed two economic actions:

  1. Move the attacker's IBC voucher into the tokenwrapper module account

  2. Release native uzig from the module account to the attacker

For a voucher created through the authorized wrapper route, this exchange could represent the intended refund.

For a voucher from another route that merely shared the same suffix, it became an unauthorized conversion of unrelated IBC assets into protocol-owned native funds.

The voucher did not need to be forged or malformed.

It only needed a different trace ending in the configured base denom.

The attack sequence

The attack required no privileged role.

The complete flow was:

1. The attacker holds a valid IBC voucher on ZIGChain

2. Its trace ends in the configured module denom

3. Its full trace differs from the wrapper's authorized trace

4. The attacker causes a packet using that denom to reach timeout handling

5. The middleware extracts only the base denom

6. The base denom matches moduleDenom

7. The packet enters wrapper-specific refund logic

8. The attacker's voucher is locked in the module account

9. Native uzig is released to the attacker

10. The process is repeated until reserves are exhausted
Enter fullscreen mode Exit fullscreen mode

The attacker did not need:

  1. Administrator permissions

  2. Validator control

  3. Governance access

  4. Keeper compromise

  5. Oracle manipulation

  6. Malformed acknowledgement data

The proof demonstrated the timeout path with valid packet data and two real, distinct denom traces sharing the same base denom.

Why an alternate channel was sufficient

IBC asset identity is path-dependent.

The same base denom can arrive through different channels and produce different voucher denoms:

Authorized wrapper asset
transfer/channel-12/unit-zig

Alternate-channel asset
transfer/channel-999/unit-zig

Base denom
Same

Full trace
Different
Enter fullscreen mode Exit fullscreen mode

The attacker did not need to reproduce the authorized voucher hash.

The precondition was simply possession of a voucher whose base denom matched moduleDenom while its full trace did not match the wrapper-produced trace.

The middleware performed the misclassification itself.

What the proof demonstrated

The integration-style proof used:

Test
TestDualDefense_TimeoutRefund

Location
x/tokenwrapper/module/dualdefense_test.go
Enter fullscreen mode Exit fullscreen mode

Its setup defined:

Configured module denom
unit-zig

Expected wrapper trace
transfer/channel-12/unit-zig

Untrusted trace
transfer/channel-999/unit-zig
Enter fullscreen mode Exit fullscreen mode

The expected and untrusted traces generated different IBC voucher denoms, proving that they were distinct assets.

Initial balances

The attacker began with no native uzig.

The module account was funded with:

15,000,000,000 uzig
Enter fullscreen mode Exit fullscreen mode

The attacker was given enough untrusted vouchers for three timeout refunds.

Each refund iteration

Every iteration produced:

Attacker uzig
+5,000,000,000

Module uzig
-5,000,000,000

Attacker untrusted voucher
-5,000,000,000,000,000,000,000

Module untrusted voucher
+5,000,000,000,000,000,000,000
Enter fullscreen mode Exit fullscreen mode

Final balances

After three iterations:

Attacker uzig
15,000,000,000

Module uzig
0

Attacker untrusted voucher
0

Module untrusted voucher
15,000,000,000,000,000,000,000
Enter fullscreen mode Exit fullscreen mode

The funded native reserve was drained completely.

The test completed successfully:

PASS
Enter fullscreen mode Exit fullscreen mode

The proof did more than show that an unintended packet reached a handler.

It measured native funds leaving the module account and entering the attacker's account on every iteration.

On-chain reserve evidence

The report also checked the ZIGChain testnet at block height:

3,858,150
Enter fullscreen mode Exit fullscreen mode

The tokenwrapper module account was:

zig1hdq87rzf327fwz8rw9rnmchj7qa3uxrpxds2fw
Enter fullscreen mode Exit fullscreen mode

Its balance at that height was:

19,155,057 uzig
Enter fullscreen mode Exit fullscreen mode

This confirmed that the affected module account held a nonzero native reserve on chain.

The immediate maximum loss was bounded by the module's available uzig balance, and repeated exploitation could reduce that balance to zero.

The test amounts and the observed on-chain balance were separate pieces of evidence: the test demonstrated the drain mechanism, while the chain query demonstrated that the affected reserve existed.

The acknowledgement path shared the same flaw

The proof exercised timeout handling.

The error-acknowledgement handler used the same base-denom-only eligibility pattern.

The vulnerability class therefore affected both callback paths:

Timeout
Wrapper-specific refund logic

Error acknowledgement
Wrapper-specific tracking and refund-related logic
Enter fullscreen mode Exit fullscreen mode

The acknowledgement JSON decoding was not the problem.

Packet data could be parsed correctly while the subsequent authorization decision remained unsafe.

Both paths needed the same invariant:

Only a packet whose full denom exactly matches
the wrapper-produced trace may enter
wrapper-specific refund or tracking logic
Enter fullscreen mode Exit fullscreen mode

Why the finding was Critical

The impact was direct and measurable:

Funds transferred
Protocol-owned native uzig

Recipient
Unprivileged attacker

Maximum loss
Full tokenwrapper module uzig balance

Repeatability
Until the reserve is exhausted
Enter fullscreen mode Exit fullscreen mode

This was not:

  1. A display discrepancy

  2. A temporary accounting mismatch

  3. A denial of service without fund loss

  4. A theoretical trace collision without a payout

The proof showed native uzig moving from the module account to the attacker during every iteration.

Reserve depletion could also prevent legitimate users from completing expected wrapper refunds or conversions.

The public report assigned a CVSS v3.1 score of 9.1 and validated the finding as Critical.

Why the base denom was not enough

A base denom identifies the final denomination component.

It does not prove the route through which the voucher was created.

The vulnerable logic effectively assumed:

same base denom
means
same origin and same backing
Enter fullscreen mode Exit fullscreen mode

IBC does not provide that guarantee.

The accurate relationship is:

same base denom
means only
same final denom component
Enter fullscreen mode Exit fullscreen mode

The path may still identify a different voucher with different provenance and different reserve assumptions.

A suffix was being used as an authorization credential.

The correction

The timeout and acknowledgement gates must compare data.Denom with the exact trace produced by the wrapper send path.

The report proposed reconstructing that trace from the configured port, channel, and module denom:

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
}
Enter fullscreen mode Exit fullscreen mode

The timeout handler should delegate to the underlying application when the trace does not match:

if !im.validatePacketDenomIsExpectedWrappedSendDenom(
    ctx,
    data.Denom,
) {
    return im.app.OnTimeoutPacket(
        ctx,
        channelVersion,
        packet,
        relayer,
    )
}
Enter fullscreen mode Exit fullscreen mode

The acknowledgement handler needs the same strict gate before any wrapper-specific tracking or refund behavior is executed.

Regression tests

A complete fix should include negative tests that preserve the matching base denom while changing the route.

At minimum:

  1. The exact expected trace is accepted

  2. The same base denom through another channel is rejected

  3. A nested trace ending in the same base denom is rejected

  4. An unrelated base denom is delegated normally

  5. Timeout handling does not unlock native funds for an alternate trace

  6. Error acknowledgements do not enter wrapper-specific logic for an alternate trace

  7. Repeated malicious attempts do not reduce module uzig

  8. Legitimate wrapper refunds still work after the stricter check

The central invariant is:

Wrapper-specific refunds must be authorized by exact provenance, not by the final denom component.

Broader audit lessons

Asset identity includes provenance

In cross-chain systems, a token symbol or base denom is rarely the complete identity.

Route, channel, origin, and transformation history can determine whether an asset is backed by a particular reserve.

Refund handlers are withdrawal paths

A refund callback may look like error recovery.

Economically, it can transfer real protocol funds.

Timeout and acknowledgement handlers deserve the same authorization review as explicit withdrawal functions.

Correct parsing does not imply correct validation

The packet and acknowledgement data may be decoded correctly while the business decision remains unsafe.

Security-relevant context must survive parsing and remain part of the authorization check.

Suffix comparisons are dangerous for structured identities

Checking only the final component of a structured identifier can collapse several distinct assets into one authorization class.

The same failure pattern can appear in domains, filesystem paths, bridge routes, message origins, and token identifiers.

Strong negative tests keep one field equal

A completely unrelated denom is an easy case.

The important test uses the same base denom with a different path.

That isolates the exact assumption the gate must reject.

Conclusion

ZIGChain's tokenwrapper middleware intended to run refund logic only for packets produced by its configured wrapper route.

Instead, the timeout and acknowledgement gates extracted the base denom and compared only that suffix with moduleDenom.

That made these traces appear equivalent:

transfer/channel-12/unit-zig
transfer/channel-999/unit-zig
Enter fullscreen mode Exit fullscreen mode

They shared a base denom.

They did not share provenance.

An attacker holding the alternate-channel voucher could reach the timeout refund path, lock that voucher, and receive protocol-owned native uzig.

The proof repeated the conversion three times and reduced the funded module reserve to zero.

The engineering invariant is simple:

In IBC middleware, refund eligibility must be bound to the exact denom trace produced by the authorized send path.

Top comments (0)