
Взлом GMX, что это было?
TLDR; 9 июля 2025 года хакер обнаружил и использовал уязвимость в смарт-контракте GMX v1, что привело к краже около 42 миллионов долларов из пула ликвидности GLP на сети Arbitrum. Уязвимость типа reentrancy позволилa манипулировать шортами и завышать стоимость токена GLP. В данный момент хакер вернул 40.5 миллионов по соглашению с биржей, а GMX v1 остановлен.
Материал для поста взят из разборов от
@SlowMist_Team
(https://slowmist.medium.com/inside-the-gmx-hack-42-million-vanishes-in-an-instant-6e42adbdead0),
@SolidityScan
(https://blog.solidityscan.com/gmx-v1-hack-analysis-ed0ab0c0dd0f) а так же из X GMX (https://x.com/GMX_IO/status/1943336664102756471)
Ключевые моменты
Атака стала возможной из-за наличия двух фундаментальных недостатков дизайна GMX v1.
1. Некорректная работа с globalShortAveragePrice. Система обновляла значение глобальной цены коротких позиций только при открытии шорт, а при закрытии - нет.
2. Мгновенный рост globalShortSizes. При открытии шорт глобальный размер коротких позиций возрастает немедленно, что оказывает влияние на расчет AUM (Assets Under Management) - средства под управлением и это позволяет манипулировать ценой токена GLP в GlpManager.sol (токен ликвидности пула GMX).
В атаке так же использовалась уязвимость в Timelock.enableLeverage механизме, который вызывался во время работы Keeper (это бот, через который происходит работа с ордерами). Глобально можно сказать, что проблема заключается в десинхронизации внутренних подсчетов механизма расчета цен GMX.
Подготовка к атаке
С атакующего контракта были исполнены две транзакции: открытие длинной позиции и ордер на уменьшение позиции, который позже должен был исполнить keeper.
Когда keeper получил ордер на уменьшение позиции, он вызвал PositionManager::executeDecreaseOrder, который в свою очередь сделал внутренний вызов Timelock.enableLeverage, который в свою очередь поставил флаг _isLeverageEnabled контракта Vault в позицию true (да, атака не самая простая). Постановка этого флага - ключевой момент.
После этого метод OrderBook::executeDecreaseOrder приступает к уменьшению позиции. Позиция скорректирована, и collateral (токен залогa), в данном случае WETH, должен вернуться на атакующий контракт. Но WETH перед трансфером разворачивается в ETH и это вызывает fallback функцию атакующего контракта - здесь и начинается reentrancy
fallback функция отправляет 3001 USDC в Vault::increasePosition и открывает шорт с х30 плечом, и соответствующий ордер на закрытие летит в keeper
Как работает Vault::increasePosition
В функции increasePosition в первую очередь происходит вызов внутренней функции _validate, чтобы проверить разрешено ли использовать плечи (тот самый _isLeverageEnabled, который заранее переведен в true). Такая ситуация возможно только если операцию проводит keeper, то есть прямой вызов контракта приведет к ошибке.
Поэтому атакующий и создал заранее ордер на уменьшение позиции, который исполнит keeper, и так получил доступ к использованию плечей в своей fallback функции, с помощью которой в осуществил повторный вход (reentrancy (https://t.me/web3securityresearch/10)) и вызвал Vault::increasePosition, напрямую создав короткую позицию.
Ко всему прочему, Vault::increasePosition обновляет значение globalShortAveragePrices, когда шорт позиция открывается, а вот Vault:: decreasePosition не обновляет, когда шорт закрывает.
Это позволило использовать эти 3к USDC многократно, чтобы повлиять на globalShortAveragePrices.
В итоге хакеру удалось уменьшить среднюю цену шорта примерно в 57 раз относительно настоящей цены по маркету WBTC. Все эти операции проводились пока keeper исполнял ордер по уменьшению позиции.
Что было дальше
После того как keeper получил финальный ордер на закрытие позиции, он вызвал OrderBook::executeDecreaseOrder. Затем возврат ETH на атакующий контракт снова запустил fallback функцию, но на этот раз она:
- Взяла flashloan на Uniswap в размере 7.538кк USDC
- Вызвала RewardRouterV2::mintAndStakeGlp, где сминтила и стейкнула 4.129кк GLP за 6кк USDC
Дальше атака развивается так:
- Происходит вызов Vault::increasePosition, через который открывается шортовая позиция размером 15.385кк USD в WBTC
- Открытие этой позиции обновляет значение globalShortSizes, которое вырастает мгновенно
- Контракт вызывает unstakeAndRedeemGlp, который должен анстейкнуть и вернуть GLP токены, купленные на flashloan
Здесь остановимся подробнее. Атакующему вернули только 386к токенов GLP, еще 9.731кк USDG были сожжены а 88 BTC отправились на атакующий контракт.
Чтобы понять почему так получилось, надо понимать как работает GlpManager:: _removeLiquidity. В этом методе есть формула подсчета того, сколько USDG должно быть сожжено, выглядит она так
usdgAmount = _glpAmount * aumInUsdg / glpSupply
Потом это подсчитанное количество USDG отправляется в Vault и обменивается на желаемый актив (WBTC), AUM (Assets Under Management - активы под управлением) считается так:
aum = ((totalPoolAmounts - totalReservedAmounts) * price)
+ totalGuaranteedUsd + GlobalShortLoss
- GlobalShortProfits - aumDeduction
Из-за того, что на предыдущем шаге была создана большая шорт позиция, globalShortSizes вырос. А getGlobalShortAveragePrice был уменьшен еще чуть раньше, это привело к тому, что эта шорт позиция считается убыточной. Из-за этого резко возрастает GlobalShortLoss, оказывая влияние на AUM (Он растет, значит GLP стал дороже, а значит за один GLP биржа отдаст больше средств), что позволяет в конечном итоге получать атакующему больше активов, чем положено.
Далее атакующий просто продолжает вызывать unstakeAndRedeemGlp, получая средства от манипуляции размером AUM
На этом я остановлюсь, но в источниках, указанных в начале поста вы можете почитать про то, куда и как выводил деньги атакующий, посмотреть на транзакции, код и прочее.
Единственный непонятный мне момент заключается в том, как именно происходила манипуляция с globalShortAveragePrices, что у нее так сильно снизилось значение. Если найду ответ, до дополню пост
https://t.me/web3securityresearch 1 reply
0 recast
2 reactions
0 reply
0 recast
0 reaction
1 reply
0 recast
1 reaction