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:
unit-zig.unit-zig to Zigchain. Attacker receives an IBC voucher on Zigchain whose denom trace ends with /unit-zig but is not Axelar-originated.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.
Two PoCs were created:
app.OnAcknowledgmentPacket )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:
refund_drain_regression_test.go ) that reproduces the bug; it's only for the unit test ran with go test ...TW-021 which is skipped by default in the shell test runner run_tests.shHOME so it won't touch the real ~/.hermes or ~/.ignote chain dirs and cleans relayer state only under that temp home..gitignore just to avoid pushing them if the test ends up being used in the futureThe test shows how:
tokenwrapper denom (e.g. .../unit-zig) gets converted.uzig is paid out from the tokenwrapper module account to the sender.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
We spin up 4 local chains and 3 relayers in the background, writing the logs under a temporary WORK_DIR.
Chains:
tokenwrapper is enabled and configured in the config.yml fileaxelarddummydcosmosd, technically not necessary for TWO-021, but harness starts it for other tests.Relayers:
dummy <-> zigchainaxelar <-> zigchaincosmos <-> zigchainThe TW-021 PoC actually uses:
unit-zig vouchers on Zigchainreceiver to a string that Axelar's transfer module can't parse as an address )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