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.
Reproducing with Wake
- Clone the repository
- Import GMX project dependencies:
$ npm i
- Initialise Wake:
$ wake up
- Get an Arbitrum fork URL from Alchemy or other providers and set it in
.env
similar to.env.example
. - Run the test:
$ wake test tests/test_attack_simple.py
- 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.
Entry point
The attack begins when a user increases their position:
- User calls
createIncreaseOrder
to register the order - The orderkeeper bot calls
PositionManager.executeIncreaseOrder
to execute it - 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
- ReentrancyGuard set to
ENTERED
Vault
closes position- Sends WETH to OrderBook
- ReentrancyGuard set to
NOT_ENTERED
- ReentrancyGuard set to
OrderBook
withdraws ETH- Transfers ETH to user
User.receive
is triggeredVault.increasePosition
(exploit)- ReentrancyGuard confirms
NOT_ENTERED
- ReentrancyGuard is set to
ENTERED
- Attack continues…
- ReentrancyGuard confirms
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:
- Re-enter through the open entry point
- Add liquidity to obtain GLP tokens
- Call
increasePosition
to manipulate the GLP token price upward - 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.