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
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
That identifier is derived from a denom trace containing routing information:
port/channel/base-denom
The proof used two traces:
Expected wrapper trace
transfer/channel-12/unit-zig
Untrusted trace
transfer/channel-999/unit-zig
They shared the same base denom:
unit-zig
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:
The configured native port
The configured native channel
The configured module denom
Conceptually:
expected denom =
native port
/ native channel
/ module denom
Using the proof configuration:
transfer/channel-12/unit-zig
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(...)
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
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
The handler then performed two economic actions:
Move the attacker's IBC voucher into the tokenwrapper module account
Release native
uzigfrom 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
The attacker did not need:
Administrator permissions
Validator control
Governance access
Keeper compromise
Oracle manipulation
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
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
Its setup defined:
Configured module denom
unit-zig
Expected wrapper trace
transfer/channel-12/unit-zig
Untrusted trace
transfer/channel-999/unit-zig
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
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
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
The funded native reserve was drained completely.
The test completed successfully:
PASS
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
The tokenwrapper module account was:
zig1hdq87rzf327fwz8rw9rnmchj7qa3uxrpxds2fw
Its balance at that height was:
19,155,057 uzig
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
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
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
This was not:
A display discrepancy
A temporary accounting mismatch
A denial of service without fund loss
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
IBC does not provide that guarantee.
The accurate relationship is:
same base denom
means only
same final denom component
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
}
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,
)
}
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:
The exact expected trace is accepted
The same base denom through another channel is rejected
A nested trace ending in the same base denom is rejected
An unrelated base denom is delegated normally
Timeout handling does not unlock native funds for an alternate trace
Error acknowledgements do not enter wrapper-specific logic for an alternate trace
Repeated malicious attempts do not reduce module
uzigLegitimate 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
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)