Critical Severity Bug Disclosure: MUX
A low-level call in the liquidation path that allowed borrowers to prevent repayments to the lending pools
Last week I found a critical severity bug in the MUX GMXV2Adapter contracts. It was confirmed by their team via Immunefi and a bounty was paid.
Below is a detailed write-up explaining how a simple call could result in freezing of lenders funds.
Background: MUX, GMX V2, and borrowed margin
MUX is a decentralized perpetual trading protocol suite that includes a perpetual aggregator and liquidity infrastructure for leveraged trading. The MUX Aggregator routes trades across underlying perpetual protocols to optimize execution, liquidity, and trading costs.
MUX allows users to deploy a GMX V2 adapter through which they could open a GMX position while borrowing extra collateral from the MUX lending pool, increasing their effective margin and leverage.
The liquidation flow
When such a position becomes undercollateralized, it can be liquidated. During liquidation, the computation of the amount of collateral and fees to be repaid to the MUX Lending pool
happens in GmxV2Adapter.afterOrderExecution, a callback called by the GMX contracts.
The flow is:
- GMX executes the liquidation.
- GMX calls the adapter callback
afterOrderExecution. - The adapter computes repayment.
- The adapter repays borrowed collateral and fees to the MUX lending pool.
- The adapter updates its internal accounting state.
This is the GMX logic
function afterOrderExecution( bytes32 key, Order.Props memory order, EventUtils.EventLogData memory eventData) internal { if (!isValidCallbackContract(order.callbackContract())) { return; }
validateGasLeftForCallback(order.callbackGasLimit());
try IOrderCallbackReceiver(order.callbackContract()).afterOrderExecution{ gas: order.callbackGasLimit() }( key, order, eventData ) { } catch { emit AfterOrderExecutionError(key, order); }}And the main logic in MUX’s GMXV2Adapter callback
if (!pendingOrder.isIncreasing) { if (_store.account.debtCollateralAmount > 0) { Prices memory prices = _store.getOraclePrices(); _store.repayDebt(prices); } else { _store.refundTokens(); } } _store.claimNativeToken();In the case of a liquidation, repayDebt computes the debt to be repaid and handle the token transfers.
Then claimNativeToken sends back any ETH in the adapter back to the account owner - if the collateral token is not WETH.
function claimNativeToken(IGmxV2Adatper.GmxAdapterStoreV2 storage store) internal returns (uint256) { if (store.account.collateralToken != WETH) { uint256 balance = address(this).balance; AddressUpgradeable.sendValue(payable(store.account.owner), balance); return balance; } else { return 0; } }The vulnerability
Let us see what the sendValue function does exactly.
It send amount of ETH via a native call to recipient.
function sendValue(address payable recipient, uint256 amount) internal { if (address(this).balance < amount) { revert Errors.InsufficientBalance(address(this).balance, amount); }
(bool success, ) = recipient.call{value: amount}(""); if (!success) { revert Errors.FailedCall(); } }A low-level call transfers control to the recipient. If it is a smart contract, its receive() function can run arbitrary logic, including reverting.
This is the problem: claimNativeToken can be made to revert by store.account.owner, i.e. the borrower.
This revert makes the entire callback reverts. Why is this dangerous?
Let us look back again at the GMX logic. The callback is performed in a try/catch block.
try IOrderCallbackReceiver(order.callbackContract()).afterOrderExecution{ gas: order.callbackGasLimit() }( key, order, eventData ) { } catch { emit AfterOrderExecutionError(key, order); }This means that if the callback reverts, the error is caught by GMX, an AfterOrderExecutionError(key, order) event is emitted, and the rest of the call goes through.
This ensures a malicious order.callbackContract() cannot prevent order executions, liquidations.
In MUX’s case, the problem is that the callback handles repayments. So the revert we described above also reverts the lending pool repayment.
How it can be exploited
Anyone could perform the following:
- Deploy a contract whose
receive()function reverts. - Pass that contract as the owner of a MUX GMX V2 adapter position.
- Open a leveraged position using borrowed collateral from the MUX lending pool.
- Send a tiny amount of native token to the adapter so
claimNativeToken()has something to send. - Let liquidation execute.
- GMX catches the callback failure, while MUX repayment logic is reverted.
The malicious contract could look like this:
contract Attacker { receive() external payable { revert(); }}The impact
Liquidity providers of the MUX lending pools expect liquidations to return a portion of the borrowed margin and fees to the pool. This vulnerability allowed a malicious borrower to leave collateral stuck in the adapter and the lending pool without the repayment it should have received.
Suppose the attacker borrowed 10,000 USDC-equivalent collateral from the lending pool. On liquidation, GMX may return only a fraction of the position’s collateral to the adapter, depending on PnL, fees, price impact, and liquidation costs. The expected repayment logic in MUX is to use those funds to repay debt first, then fees. For example, if only 2,000 USDC is returned, the pool should receive 2,000 toward the debt and record the rest as bad debt. With this vulnerability, even that partial repayment is reverted, so the lending pool receives nothing and the returned collateral remains stuck in the adapter.
This issue is especially relevant in a leveraged trading system, where liquidations are more common than in a traditional spot borrowing.
The fix
Our suggestion was to place the _store.claimNativeToken() call in a try/catch block, to ensure the GMX callback afterOrderExecution would not fail.
The team went a step further,
and placed all ETH transfers inside try/catch blocks, wrapping ETH to WETH upon failure.
function trySendNativeToken(address receiver, uint256 amount) internal { (bool success, ) = receiver.call{ value: amount }(""); if (success) { return; } IWETH(WETH).deposit{ value: amount }(); IERC20Upgradeable(WETH).safeTransfer(receiver, amount); }Takeaway
The core issue was not GMX using try/catch, and it was not MUX refunding native tokens. It was a combination of several individually reasonable design choices:
- GMX made callbacks non-blocking
- MUX handle repayment logic inside these callbacks
- MUX ended the callback with a native token transfer that gave control to a user-controlled account.
Cross-protocol integrations that rely on callbacks need a clearly defined failure model. If a callback contains critical accounting logic, then every external call must be carefully reviewed.
Eraikey Labs is a blockchain security firm founded Joe Stakey, a consistently top-ranked whitehat on Immunefi since 2023. We provide security services throughout the entire development lifecycle. Get in touch.