WAX Bridge Exploit: A Negative Fee Drained ~$90K | PandaSecurity
Web3 Security

WAX Bridge Exploit: A Negative Fee Drained ~$90K

PandaSecurity Team •
#Web3 Security#Bridge Exploit#Input Validation#Smart Contract Audit
Diagram of the WAX Multi-Chain Bridge exploit: a withdraw with a negative max_fee on WAX triggering real payouts on Solana and Ethereum

On 22 October 2026, between 09:46 and 09:48 UTC, an attacker sent five withdrawal requests to the WAX Multi-Chain Bridge and walked away with about 52,986 USDC, 21,558 USDT, 840 DAI, 2.55 WETH and 0.11 WBTC, roughly $90K in total. They never held any of the bridged tokens on WAX. The whole exploit came down to one parameter, max_fee, that was allowed to be negative.

The loss is modest by bridge-hack standards. The bug class is not. Any time a contract computes “amount to charge” from user-supplied numbers, and a different system pays out based on a different number, a missing sign or range check can turn a withdrawal into free money. This post walks through how the WAX bridge accounts for withdrawals, what went wrong in eth.token::withdraw, and what bridge and token developers should take from it.

TL;DR

How the WAX Multi-Chain Bridge Works

When a user bridges USDC from Ethereum to WAX, the real USDC sits in a bridge contract on Ethereum (0x24b22B031Ae1B5a6b09F9a0FD33613a40e0d70e6) or in a bridge account on Solana. On WAX, the eth.token contract credits the user with a mapped token, for example WAXUSDC.

Going the other way, the user calls withdraw on eth.token. Based on GoPlus Security’s breakdown and the on-chain action data, the action takes five arguments:

withdraw(from, to_eth_address, quantity, max_fee, chain_name)

The contract is supposed to burn or debit the mapped tokens from from, charge a bridge fee capped by max_fee, and emit a withdrawal record. Off-chain relayers pick up that record and release the real asset on the destination chain named in chain_name.

The important point is that the destination chain cannot see WAX balances. It trusts the withdrawal record. So the WAX-side accounting is the only thing standing between a withdrawal request and a real payout.

The Vulnerability: Signed Arithmetic on a Fee

Antelope (the EOSIO family WAX is built on) represents token amounts with the asset type, which wraps a signed 64-bit integer. That means an asset can be negative unless the contract checks otherwise. Standard token contracts guard every entry point with something like check(quantity.amount > 0, "must transfer positive quantity").

SlowMist, GoPlus and Arisk all describe the same flaw in withdraw:

  1. The contract computed the amount to debit as quantity + max_fee.
  2. It checked that quantity was valid, but it did not enforce max_fee >= 0.
  3. The configured bridge fee was zero, so the code path that compares the actual fee to max_fee was skipped.
  4. With a debit of exactly zero, no balance was actually subtracted. GoPlus notes that a zero-amount debit does not hit the sub_balance path that would have failed on an empty account.
  5. The withdrawal record was still written with the full quantity.

The contract source has not been published alongside the incident reports, so here is a simplified, illustrative version of the pattern in Antelope C++. It is not the actual eth.token code:

// Simplified and illustrative only. Not the real eth.token source.
void withdraw(name from, std::string to, asset quantity, asset max_fee, name chain) {
    require_auth(from);
    check(quantity.is_valid() && quantity.amount > 0, "invalid quantity");
    // Missing: check(max_fee.amount >= 0, "max_fee must be non-negative");

    asset fee = get_configured_fee(chain, quantity.symbol);   // 0 in this case
    if (fee.amount > 0) {
        check(fee <= max_fee, "fee exceeds max_fee");          // skipped when fee == 0
    }

    asset debit = quantity + max_fee;                         // 52,986 + (-52,986) = 0
    if (debit.amount > 0) {
        sub_balance(from, debit);                             // never runs
    }

    emit_withdrawal_record(from, to, quantity, chain);         // full amount recorded
}

Two separate numbers drive two separate systems. The debit on WAX is quantity + max_fee. The payout on the destination chain is quantity. Once those can differ by an attacker-chosen amount, the bridge is printing withdrawals.

The same mistake shows up in Solidity whenever a contract uses int256 for a fee, a delta or an adjustment and then adds it to an amount. Here is a simplified Solidity analogue:

// Simplified and illustrative only.
function withdraw(uint256 amount, int256 maxFee, address to) external {
    // Missing: require(maxFee >= 0, "negative fee");
    uint256 debit = uint256(int256(amount) + maxFee); // 0 when maxFee == -amount
    balances[msg.sender] -= debit;                    // subtracts nothing
    emit WithdrawalRequested(msg.sender, to, amount); // relayer pays out amount
}

The Attack, Step by Step

We pulled the attacker’s actions from a public WAX history (Hyperion) endpoint. Times below are UTC on 22 October 2026.

  1. 09:06 The attacker’s WAX account, fasdfas.al, receives a tiny amount of wrapped ETH on WAX (about 0.001 ETH).
  2. 09:41 They swap a quarter of it on Alcor for about 146 WAX, enough to pay for resources. At 09:47 they stake 51 WAX for bandwidth.
  3. 09:46:18 First exploit transaction. The account calls eth.token::open for WAXUSDC and then withdraw with quantity = 52986.497732 WAXUSDC, max_fee = -52986.497732 WAXUSDC and chain_name = solana, sending to the Solana address 4H1piNZ8LsYhZGYhkbJDbPDSghxRj3HWxJqVz5dve8Q7.
  4. 09:47:31 to 09:47:44 Four more open plus withdraw pairs, each with max_fee set to exactly minus the quantity, all to Ethereum address 0x01664E199991b2F3fe412c2DE5B9b7A0dcDc5070:
    • 21,558.252354 WAXUSDT
    • 840.27503390 WAXDAI
    • 2.54669928 WAXWETH
    • 0.10993227 WAXWBTC

Notice the open calls. The attacker had to create empty balance rows for each mapped token first, which confirms they held no WAXUSDC, WAXUSDT, WAXDAI, WAXWETH or WAXWBTC before withdrawing. GoPlus also checked get_currency_balance and reports zero for all five tokens throughout.

The bridge then did what it was built to do. The Solana side released the USDC, and the Ethereum bridge contract released USDT, DAI, WETH and WBTC.

Where the Funds Went

Arisk traced the Ethereum leg. The attacker swapped the USDT for about 8.94 ETH through KyberSwap, the WBTC for about 3.74 ETH and the DAI for about 0.35 ETH, and unwrapped the WETH. The address then moved about 15.58 ETH to an intermediary, which forwarded it to a HitBTC hot wallet about 19 minutes later. Roughly 38 minutes passed between the first bridge payout and the deposit to the exchange.

Why This Kept Working Five Times

Blockaid flagged the exploit as ongoing, and SlowMist confirmed that the requests produced bridge withdrawal logs and matching payouts on both destination chains. Five withdrawals across two chains in under two minutes suggests nothing on the relayer side was checking whether a withdrawal had actually been paid for on WAX. That is the second lesson of this incident: the destination side of a bridge rarely has enough context to catch a bad record, so the source-side contract has to be airtight, and monitoring has to be fast.

Lessons for Bridge and Token Developers

Validate every numeric input, including the “optional” ones

Fee caps, slippage limits and tip amounts are easy to treat as secondary. They still flow into accounting. On Antelope, check amount > 0 or amount >= 0 on every asset, and confirm the symbol matches the expected token. In Solidity, prefer uint256 for anything that should never be negative, and if you must use int256, bound it explicitly before mixing it with unsigned amounts. Our post on the Notional V1 uint128 overflow shows how quickly numeric edge cases turn into losses.

Derive the payout from the debit, not the other way round

The cleanest fix is structural. Debit quantity in full, then subtract the actual fee from what gets paid out. If the amount recorded for the destination chain is always computed from tokens that were actually removed, a parameter mistake cannot create value.

Never let a zero debit bypass the balance check

If the code skips sub_balance or a balance require when the amount is zero, make sure the withdrawal is also rejected in that case. A withdrawal that costs nothing should fail loudly.

Add rate limits and anomaly checks on the release side

Relayers and destination contracts should enforce per-asset and per-window limits, and alert when a large withdrawal comes from an account with no deposit history. The Sui Bridge claim bot incident is another recent example of a bridge release path doing exactly what it was told with no second look.

For auditors

Our smart contract security checklist includes these input validation and accounting checks.

Key Addresses and Transactions

Sources

Closing Thoughts

The WAX bridge did not fall to clever cryptography or a compromised key. It fell to a fee parameter that nobody expected to be negative. Bridges concentrate value behind a single accounting check on the source chain, so every number that feeds a withdrawal record deserves a sign check, a range check and a test where someone passes something absurd.

Building or upgrading a bridge, or any contract that handles fees and withdrawals? Contact Panda Security for a review before it holds user funds, or see our audit pricing.

Related articles

← Back to Blog