This analysis examines the 42M attack on the GMX protocol. We provide a detailed technical breakdown of the vulnerability and include a working reproduction of the attack scenario for educational purposes in a forked environment.

The attack exploited a cross-contract reentrancy vulnerability that bypassed access controls during position increases. This resulted in GLP token price manipulation with a higher price, allowing attackers to redeem tokens at the manipulated price and extract profits from the protocol.

wake test output

Reproducing with Wake

  1. Clone the repository
  2. Import GMX project dependencies:
    $ npm i
  3. Initialise Wake:
    $ wake up
  4. Get an Arbitrum fork URL from Alchemy or other providers and set it in .env similar to .env.example.
  5. Run the test:
    $ wake test tests/test_attack_simple.py
  6. Uncomment print(tx.call_trace) to see the call trace.

Root cause

The vulnerability stems from a reentrancy issue. While the reentrancy itself is straightforward, its impact is significant.

The core issue: GLP token price calculation relies on the globalShortAveragePrices variable from ShortsTracker. This dependency creates an exploitable attack vector.

The vulnerability is cross-contract reentrancy. Multiple contracts were involved during the transaction. Each contract has a reentrancy guard; however, reentrancy occurred after it had already exited for one specific contract.

For a detailed explanation of cross-contract reentrancy, see this comprehensive guide.

Entry point

The attack begins when a user increases their position:

  1. User calls createIncreaseOrder to register the order
  2. The orderkeeper bot calls PositionManager.executeIncreaseOrder to execute it
  3. Within executeIncreaseOrder, ShortsTracker.updateGlobalShortData is invoked

ShortsTracker.updateGlobalShortData stores the globalShortAveragePrice for the token — the average entry price of all short positions. This value directly influences GLP token price calculations.

contract PositionManager {
    function executeIncreaseOrder(
        address _account,
        uint256 _orderIndex,
        address payable _feeReceiver
    ) external onlyOrderKeeper {
        //...
        IShortsTracker(shortsTracker).updateGlobalShortData(_account, collateralToken, indexToken, isLong, sizeDelta, markPrice, true);

        ITimelock(timelock).enableLeverage(_vault); // isLeverageEnabled <- True
        IOrderBook(orderBook).executeIncreaseOrder(_account, _orderIndex, _feeReceiver);
        ITimelock(timelock).disableLeverage(_vault); // isLeverageEnabled <- False

        _emitDecreasePositionReferral(_account, sizeDelta);
    }
}

External calls follow this path to reach the Vault:

  • OrderBook.executeIncreaseOrder
    • Router.pluginIncreasePosition
      • Vault.increasePosition

The decreasePosition flow follows a similar pattern.

The Vault.increasePosition function checks that isLeverageEnabled equals True to verify that the call occurs between Timelock.enableLeverage and Timelock.disableLeverage. This check proved insufficient.

contract Vault {
    // function has no msg.sender check.
    // Assumes caller transfers tokens or at least the caller is trusted.
    function increasePosition(
        address _account,
        address _collateralToken,
        address _indexToken,
        uint256 _sizeDelta,
        bool _isLong
    ) external override nonReentrant {
        _validate(isLeverageEnabled, 28); // this will be bypassed
        _validateGasPrice();
        _validateRouter(_account);
        ...
    ...
}

During Vault.decreasePosition, the contract transfers collateral tokens for closed positions. When the collateral token is WETH, the system withdraws ETH and transfers it to the user’s account. Notably, these WETH operations occur outside the Vault contract.

The call flow proceeds as follows:

  • OrderBook.executeDecreaseOrder
    • Router.pluginDecreasePosition
      • Vault.decreasePosition
        1. ReentrancyGuard set to ENTERED
        2. Vault closes position
        3. Sends WETH to OrderBook
        4. ReentrancyGuard set to NOT_ENTERED
    • OrderBook withdraws ETH
    • Transfers ETH to user
      • User.receive is triggered
        • Vault.increasePosition (exploit)
          1. ReentrancyGuard confirms NOT_ENTERED
          2. ReentrancyGuard is set to ENTERED
          3. Attack continues…

The reentrancy guard in Vault starts as NOT_ENTERED, but the reentrant call occurs after this status has been reset, bypassing the protection.

Attack escalation

The direct Vault.increasePosition call bypasses ShortsTracker.updateGlobalShortData, causing GlpManager.getAum to return inflated values and artificially increase the GLP token price.

Attack sequence:

  1. Re-enter through the open entry point
  2. Add liquidity to obtain GLP tokens
  3. Call increasePosition to manipulate the GLP token price upward
  4. Remove liquidity at the inflated GLP token price

Operational details

The attacker utilises RewardRouterV2.mintAndStakeGlp because GLPManager.inPrivateMode is enabled, preventing direct calls to GLPManager.addLiquidity.

The attacker used a flash loan with USDC in fallback function to create a large WBTC short position.

Summary

The attack succeeded due to fragmented data responsibility across contracts. Critical state information was split between ShortsTracker and Vault, rendering the reentrancy guard ineffective. This architectural vulnerability allowed attackers to manipulate GLP token prices through carefully orchestrated re-entrant calls, enabling the multimillion exploit.