GMX V1 $42M Case Research: Reentrancy and the Split-Brain Short Accounting That Forged a GLP Price
When a perp DEX gets drained, the headline is always "reentrancy" — but reentrancy is just the door. The interesting question on GMX is: what state did the attacker desynchronize while the door was open, and why did the protocol price its own LP token off that broken state? The $42M is the symptom. The disease is two numbers — a global short size and a global short average price — that were supposed to move together and didn't.
This is a reconstruction from public forensic material (SlowMist's transaction-level breakdown, Sherlock's analysis with Blackthorn/Panprog, CertiK, rekt.news, BlockSec/PeckShield disclosures). Every on-chain value below is taken from those reports and is verifiable on Arbiscan. I'll flag where I'm quoting exact bytes and where a code snippet is representative.
The takeaway I keep coming back to: an AUM model is only as trustworthy as the weakest write path into the variables it reads. GMX read short size from one contract and short price from another, and only one of them updated on a direct Vault call.
Target architecture: GLP, AUM, and a price that floats on internal state
GMX V1 on Arbitrum is a perp + spot DEX. Liquidity providers deposit assets into a single non-upgradeable Vault and receive GLP, the pool token. GLP isn't pegged — its price floats with the pool's net worth:
glpPrice = AUM / glpSupply
redeem = glpAmount * AUM / glpSupply
So everything depends on AUM (Assets Under Management). And AUM is not just "sum of tokens in the vault." It nets in the unrealized PnL of every open position, because LPs are the counterparty to traders. The relevant term for this incident is the global shorts uPnL (per Sherlock/Panprog, this is the exact shape):
uPnL_shorts = globalShortSize * (averagePrice - markPrice) / averagePrice
AUM = (poolAmounts - reservedAmounts) * price
+ guaranteedUsd
+ globalShortLoss // shorts underwater -> ADDS to AUM
- globalShortProfit // shorts in profit -> SUBTRACTS from AUM
- aumDeduction
Read that PnL sign convention carefully, because it's the whole exploit. When the pool's shorts are losing, that loss is value owed to the LPs, so it is added to AUM and GLP gets more expensive. If you can make the protocol believe the global shorts are catastrophically underwater, you inflate AUM, and GLP mints cheap / redeems rich.
Two state variables feed that term, and here is the trust boundary nobody drew clearly:
globalShortSizes[token]— lives in the Vault, updated directly insideincreasePosition/decreasePosition.globalShortAveragePrices[token]— the AUM calculation reads it from the ShortsTracker, a separate contract (shortsTrackerAveragePriceWeight == BASIS_POINTS_DIVISOR, so AUM uses the tracker's value verbatim).
The ShortsTracker is updated by the PositionManager when it mediates a position change. It is not updated if you reach the Vault directly. Hold onto that asymmetry — it's the entire game.
Root cause #1: two writers, one invariant, no lock
Here is the desync in representative form (function names are real GMX V1; the bodies are simplified to the relevant lines):
// Vault.sol — direct, low-level. Bumps SIZE immediately.
function increasePosition(...) external {
_validate(isLeverageEnabled, 28); // gate #1 — see root cause #2
// ...open/extend position...
if (!_isLong) {
globalShortSizes[_indexToken] += _sizeDelta; // SIZE moves here
}
}
// PositionManager.sol — the ONLY path that also syncs the tracker.
function executeDecreaseOrder(...) external onlyOrderKeeper {
shortsTracker.updateGlobalShortData(...); // PRICE moves here — skipped on direct Vault calls
// ...routes into Vault.decreasePosition...
}
SlowMist frames the same flaw from the position lifecycle angle: opening a short updates the global short average price; closing a short does not. Combine the two observations and you get a primitive: if the attacker opens shorts directly on the Vault (size goes up, tracker price stays stale) and closes them through the keeper path in a way that never re-anchors the average back to spot, the recorded averagePrice drifts away from the real market price — and stays there.
Crucially, globalShortSize and globalShortAveragePrice are supposed to be two halves of one fact. Splitting their writers across two contracts, with no shared reentrancy lock, meant an attacker mid-reentrancy could move one without the other. The uPnL term size * (avg - price) / avg then reports fiction.
Root cause #2: the reentrancy door — refund before disableLeverage()
Direct Vault.increasePosition is gated by _validate(isLeverageEnabled, ...). Normally leverage is off; the Vault rejects direct calls. The PositionManager flips it on only for the instant it's executing a keeper order:
// PositionManager — order execution sandwich
timelock.enableLeverage(vault); // 1) open the gate
// ...execute the user's decrease order...
// -> released collateral (WETH) unwrapped to ETH
// -> ETH sent to the position's receiver <-- (A) attacker's receive() fires HERE
timelock.disableLeverage(vault); // 2) close the gate — TOO LATE
Per Sherlock/Panprog, the fatal ordering is at (A): in OrderBook.executeDecreaseOrder, the profit/collateral is paid out as native ETH to the receiver before disableLeverage() runs. If the receiver is a contract, its receive() executes while leverage is still enabled. That is the reentrancy window — and inside it, _validate(isLeverageEnabled, 28) passes, so the attacker can call Vault.increasePosition directly.
Panprog's point about why audits missed it for years is the part worth internalizing as a working auditor: reentrancy was reachable here for a long time, but it looked harmless — you could reenter and reorder some events, nothing more. The exploitability only appeared once you connected it to the other defect (the split short-accounting). Two individually "low severity" findings multiplied into a critical. That's the realistic failure mode of audits, not a missed nonReentrant.
Setup: priming the average price ~57x below spot
Now watch the attacker assemble the primitive. (Attacker 0xdf33…5221, attack contract 0x7d3b…c355, all on Arbitrum.) Initial gas came from 2 ETH out of Tornado Cash on July 7, bridged to Arbitrum via Mayan — a deliberately clean funding trail.
- Open a small long for the attack contract, then place a decrease order the keeper will execute.
- The keeper calls
PositionManager.executeDecreaseOrder→enableLeverage→OrderBook.executeDecreaseOrder→ released WETH is unwrapped and the ETH refund hits the attack contract'sreceive(). - Inside that
receive(), the attacker transfers ~3,001 USDC into the Vault and callsVault.increasePositiondirectly to open a 30x WBTC short (passes only because leverage is still enabled), then creates a matching decrease order for the keeper. - The keeper later closes that short. Opening bumped the average; closing left it untouched.
Repeat. Each loop walks globalShortAveragePrices[WBTC] further from reality. SlowMist captured the exact on-chain values:
- First reentrant short — recorded
getGlobalShortAveragePrice(WBTC)≈108757787000274036210359376021024492(~$108,757, i.e. real BTC price at the time). - Last short before the main attack — ≈
1913705482286167437447414747675542(~$1,914).
That's roughly 57× below spot. The protocol now believed its WBTC shorts were entered at $1,914 while BTC traded near $108k — a gigantic phantom loss waiting to be scaled up.
The drain transaction: mint cheap, inflate, redeem rich
Everything converges in one transaction: 0x03182d…26ef. Triggered, again, from inside a keeper-driven receive():
- Flash loan. Borrow ~7.538M USDC from Uniswap.
- Mint GLP at the honest price. Call
RewardRouterV2.mintAndStakeGlpwith ~6M USDC → mint ~4.129M GLP. At mint,aumInUsdg≈46942248263037264990037614(~$46.94M). At this instantglobalShortSizes[WBTC]≈15373061114092959107000000000000000(~$15.37M) and the average is still the stale ~$1,914. The attacker now holds GLP bought before the final size spike. - Spike the size, leave the price stale. Call
Vault.increasePositiondirectly to open a ~$15.385M WBTC short.globalShortSizes[WBTC]jumps immediately — but nothing re-anchors the tracker average. The uPnL termsize * (avg - price)/avgnow reports a massive loss (hugesize, average pinned 57× under spot,hasProfit == false), soglobalShortLossballoons and AUM is inflated by hundreds of percent. - Redeem rich. Call
unstakeAndRedeemGlp. InGlpManager._removeLiquiditythe burn math isusdgAmount = glpAmount * aumInUsdg / glpSupply. With AUM forged upward, a small amount of GLP commands a huge payout: SlowMist observed ~386k GLP burning ~9.731M USDG and pulling out ~88 WBTC (~$9.5M) in a single redeem. - Repeat the redeem against the inflated AUM to sweep the rest of the Vault — ETH, BTC, USDC, FRAX, and more — then unwind the short to close the loop and repay the flash loan.
The elegance is that no step is individually illegal. Minting, opening a position, and redeeming GLP are all normal operations. The attacker just made sure the Vault's pricing brain was reading a number that two desynced writers had quietly corrupted.
Transaction trail: following the money out
Total extracted ≈ $42M to attacker 0xdf33…5221. Per SlowMist's MistTrack analysis, dispersion looked like a competent (not state-level) operator covering ground fast:
- Bridged WETH/WBTC/DAI from Arbitrum to a hop wallet
0x99cd…2daeand out via CoW Swap, Across, Stargate, Mayan. - Converted the bag toward DAI then ETH (shedding freezable stablecoin exposure), consolidating ~11,700 ETH into
0x6acc…784c1. - Parked ~3,000 ETH each across several fresh Ethereum wallets (
0xa33f…d8a3,0xe9ad…f7e7,0x69c9…1d95,0x639c…6605).
Then the plot twist that makes this a white-hat case: two days later the attacker messaged on-chain that funds would be returned, sent ~$37M back to GMX, and kept the offered 10% bounty (~$4.2–5M). GMX had halted V1 trading/mint/redeem on Arbitrum and Avalanche immediately; V2 was unaffected.
Key contracts for your own replay:
- Attack tx —
0x03182d…26ef - Attacker EOA —
0xdf33…5221 - Attack contract —
0x7d3b…c355 - GlpManager (AUM/price) —
0x3963…ec18 - Vault —
0x489e…7C4A - ShortsTracker —
0xf58e…434da - OrderBook —
0x09f7…2ACB
Full kill chain
- 2022 — After a Collider bug-bounty finding, GMX moves short-average tracking out of the (non-upgradeable) Vault into the PositionManager/ShortsTracker path. The desync is born here, latent.
- Jul 7, 2025 — 2 ETH withdrawn from Tornado Cash, bridged to Arbitrum for gas.
- Jul 9, pre-attack — Attack contract deployed; priming loops walk
globalShortAveragePrices[WBTC]from ~$108,757 down to ~$1,914. - Jul 9, attack tx
0x03182d…— Flash-loan → mint 4.129M GLP at honest AUM → open $15.385M direct Vault short to spike size → redeem GLP against ~hundreds-of-percent inflated AUM → repeat → unwind. ~$42M drained. - Jul 9, minutes later — PeckShield/BlockSec flag the tx; GMX halts V1 trading and GLP mint/redeem; offers 10% bounty.
- ~Jul 11 — Attacker returns ~$37M, keeps the bounty.
The asymmetry here is different from a Lazarus job: no stolen keys, no compromised frontend, no social engineering. Just a deep reading of two-year-old accounting code and a reentrancy nobody thought was weaponizable.
What I'd actually change (operator view)
Having traced it end to end, the controls that would have broken this chain:
- One lock around the whole invariant.
globalShortSizesandglobalShortAveragePricesare one fact. If a singlenonReentrantguard had spanned the order-execution sandwich (and direct Vault entry during it), the mid-refund reentry intoincreasePositiondies. Reentrancy guards aren't ceremony; they enforce that paired writes can't be split. - Refund last. Move every external value transfer (the ETH unwrap/send) to after
disableLeverage()and after all state is finalized. Checks-effects-interactions, but specifically: never hand control to a receiver while a privileged flag is still flipped on. - Sanity-bound the GLP price. Revert if
glpPricemoves more than X% within a block / short window. A 57× swing in an internal average, or a multi-hundred-percent AUM jump in one tx, should be uncomputable, not just unmonitored. - Don't read one fact from two contracts. If AUM needs
sizeandavgPrice, both must come from a source that updates atomically on every write path — including direct ones. A value that "must be manually synced by the wrapper" is a desync waiting for a reentrancy. - Off-chain circuit breaker on GLP price. Auto-halt mint/redeem if GLP leaves a pre-defined band. The drain was one transaction, but the priming loop ran for many — that's a detectable pattern if anyone's watching the average-price variable, not just balances.
Confidence notes
- Firm: the attack tx, attacker/contract/Vault/GlpManager addresses, and the two
getGlobalShortAveragePricevalues (~$108,757 → ~$1,914),globalShortSizes(~$15.37M), andaumInUsdg(~$46.94M) are quoted from SlowMist's analysis and verifiable on Arbiscan. - Firm: the root cause (refund-before-
disableLeveragereentrancy + Vault/ShortsTracker desync) is independently described by Sherlock/Panprog and SlowMist and is consistent with rekt.news and BlockSec/PeckShield disclosures. - Representative, not verbatim: the Solidity snippets are simplified to the relevant lines (real function names, condensed bodies). Read the on-chain GMX V1 source for the exact
_updateGlobalShortDataaveraging math and_validateindices. - Firm: the white-hat return (~$37M) and 10% bounty are confirmed by GMX and the attacker's on-chain messages.
Sources
- SlowMist — GMX hack analysis ($42M, transaction-level breakdown, MistTrack flows):
https://www.secrss.com/articles/80778 - Sherlock × Blackthorn (Panprog) — GMX Exchange Hack Explained (code-level root cause):
https://sherlock.xyz/post/gmx-exchange-hack-explained - CertiK — GMX Incident Analysis (contracts, step-by-step):
https://www.certik.com/skynet-report/gmx-incident-analysis - rekt.news — GMX (attacker trail, timeline, partner attributions):
https://rekt.news/gmx-rekt - GMX — official incident statements / bounty offer:
https://x.com/GMX_IO
