Originally published at WAX Bridge Exploit: A Negative Fee Drained ~$90K
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
- Protocol: WAX Multi-Chain Bridge, which maps tokens between WAX, Ethereum and Solana.
-
Vulnerable contract:
eth.tokenon WAX, the contract that tracks balances of mapped tokens such as WAXUSDC, WAXUSDT, WAXDAI, WAXWETH and WAXWBTC. - Loss: about $90K (SlowMist estimates ~$90.5K). Stablecoins alone were about $75.4K.
-
Root cause:
withdrawdebitedquantity + max_feefrom the sender but never requiredmax_fee >= 0. Passingmax_fee = -quantitymade the debit zero while the withdrawal record still carried the fullquantity. - Result: the Solana and Ethereum sides of the bridge saw valid withdrawal records and paid out in full.
- Status: WAX paused bridge processing and said the WAX chain and WAXP were not affected.
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:
- The contract computed the amount to debit as
quantity + max_fee. - It checked that
quantitywas valid, but it did not enforcemax_fee >= 0. - The configured bridge fee was zero, so the code path that compares the actual fee to
max_feewas skipped. - With a debit of exactly zero, no balance was actually subtracted. GoPlus notes that a zero-amount debit does not hit the
sub_balancepath that would have failed on an empty account. - 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.
-
09:06 The attacker's WAX account,
fasdfas.al, receives a tiny amount of wrapped ETH on WAX (about 0.001 ETH). - 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.
-
09:46:18 First exploit transaction. The account calls
eth.token::openforWAXUSDCand thenwithdrawwithquantity = 52986.497732 WAXUSDC,max_fee = -52986.497732 WAXUSDCandchain_name = solana, sending to the Solana address4H1piNZ8LsYhZGYhkbJDbPDSghxRj3HWxJqVz5dve8Q7. -
09:47:31 to 09:47:44 Four more
openpluswithdrawpairs, each withmax_feeset to exactly minus the quantity, all to Ethereum address0x01664E199991b2F3fe412c2DE5B9b7A0dcDc5070:- 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
- List every user-controlled number in deposit and withdraw paths and write down its allowed range. Then test the boundaries: zero, negative, maximum, and values equal to minus another argument.
- Trace both sides of the accounting. Confirm that the value debited on the source chain and the value recorded for the destination chain come from the same variable.
- Check configuration-dependent branches. Here the fee check only ran when the configured fee was above zero. Review the code with each realistic configuration, not just the default one in the tests.
Our smart contract security checklist includes these input validation and accounting checks.
Key Addresses and Transactions
- Attacker (WAX):
fasdfas.al - Attacker (Solana):
4H1piNZ8LsYhZGYhkbJDbPDSghxRj3HWxJqVz5dve8Q7 - Attacker (Ethereum):
0x01664E199991b2F3fe412c2DE5B9b7A0dcDc5070 - Vulnerable contract (WAX):
eth.token - Bridge (Ethereum):
0x24b22B031Ae1B5a6b09F9a0FD33613a40e0d70e6 - Bridge (Solana):
8cqv3kRg1ivwPNrGGrvJANXb6RPik6XEhnBzs18tPcKC - WAXUSDC withdraw (to Solana):
406b76e8...518fb0c9 - WAXUSDT withdraw (to Ethereum):
8523e7f4...7154a668 - WAXDAI withdraw:
2443e557...d4935b51 - WAXWETH withdraw:
f7cd1874...28d7ce6f - WAXWBTC withdraw:
97674d09...d1a85c74
Sources
- Blockaid alert on X and address thread: detection, asset amounts and addresses
- SlowMist TI Alert on X: loss estimate, root cause and victim addresses
-
GoPlus Security breakdown on X:
withdrawsignature, zero-debit behaviour and attacker balances - Arisk fund-flow analysis on X and root-cause follow-up: swaps and HitBTC deposit
- WAX official statement on X: bridge pause and scope
- Withdraw action data and timestamps, read by us from a public WAX Hyperion history endpoint
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.
Top comments (1)
A negative fee that passes a bounds check is the same class of bug as an unclamped index. A fuzz test on the fee parameter with negatives, zero and max u256 would have caught it before withdrawal.