背景
2025 年 7 月 9 日,GMX V1 遭受黑客攻击,损失约 4200 万美元资产。攻击者利用 executeDecreaseOrder 函数发送 ETH 的行为进行重入,绕过 enableLeverage 检查和 globalShortAveragePrices 的更新进行开仓,从而操纵全局空头平均价格(globalShortAveragePrices),抬高 GLP 代币的价值。最后将 GLP 以池内资产(BTC、ETH、USDC 等)的形式赎回完成获利。
GMX V1 是一个去中心化永续合约交易平台,允许用户以最高 30 倍杠杆交易加密资产(如 ETH、BTC)通过 GLP 池作为合约用户对手方。流动性提供者(LP)通过存入资产(如 USDC、ETH)获得 GLP 代币。合约用户可开多头或空头头寸,盈亏以 USD 计价。平台通过 Chainlink 预言机获取价格,Keeper 自动化执行清算和限价单,确保效率和安全性。
整个攻击事件涉及 14 笔交易,其中 1-13 笔是准备交易,第 14 笔是攻击交易。
Prepare transaction [TX 1-13]
要把这些准备交易全部找出来排好序真的不容易啊,每笔交易的发起者是不同的,所调用的合约也不同的。所以只能够通过各种 Key 和 Index 来排查每笔交易之间的顺序关系,确保没有遗漏掉相关的交易。
- positionKey 对应的是 position
- requestKey 对应的是 request
- increaseOrdersIndex 对应的是 order,从 0 开始
- decreasePositionsIndex 对应的是 request,从 1 开始
TX 1
355878385\]
TX 2
355878605\]
TX 3
355878984\]
TX 4
355879148\]
- (In reentrancy)
Vault.increasePosition()
: 绕过 enableLeverage 检查和 globalShortAveragePrices 的更新,直接创建 WBTC short position(抵押品为 3001 USDC) [positionKey = 0x255b] - (In reentrancy)
Position Router.createDecreasePosition()
: 创建 WBTC short position 的平仓 request [requestKey = 0xc239, decreasePositionsIndex = 1]

此时一些相关参数的值
price = 109469868000000000000000000000000000
In ShortsTracker:
[before]ShortsTracker.globalShortAveragePrices = 108757787000274036210359376021024492
绕过 globalShortAveragePrices 的更新会出现什么情况呢?
globalShortAveragePrices
代表的是总体空头仓位的平均价格,也就是说当现货价格与平均价格相等时,则到达了不亏不赚的成本价。
- 如果正常进行开仓操作,更新
globalShortAveragePrices
的值,会往现货价格 Price 的值靠拢。(比如现货价格高于平均价格,那么采用现货价格开空时,会抬高平均价格) - 而当进行减仓操作时,如果获利,则上调
globalShortAveragePrices
的值,如果亏损,则下调globalShortAveragePrices
的值。(比如在现货价格高于平均价格时减仓,首先仓位的亏损金额不会变,剩余仓位需要到达更低的价格才能填补上减仓部分的亏损)
正常情况下, increasePosition
需要 Keeper 调用 PositionManager.executeIncreaseOrder()
作为入口,此时会执行 ShortsTracker.updateGlobalShortData()
更新 ShortsTracker.globalShortAveragePrices
数据。

而攻击者通过重入绕过 Timelock
和 getIncreaseOrder
直接调用 Vault.increasePosition()
,则不会更新 ShortsTracker.globalShortAveragePrices
的值,维持 globalShortAveragePrices
在 108757
没有向现货价格 109394
靠拢。
而在 TX 5 中,当 Keeper 执行 Position Router.executeDecreasePosition()
的时候会更新 ShortsTracker.globalShortAveragePrices
的值
- 开仓时缺失了一次更新,使得所采用的值会比实际值要小。
- 加上是亏损的减仓操作,所以
globalShortAveragePrices
的值会进一步减小。
TX 5
355879171\]

此时一些相关参数的值,globalShortAveragePrices
已经被更新成了更小的值。
price = 109505774000000000000000000000000000
In ShortsTracker:
[beforeUpdate]ShortsTracker.globalShortAveragePrices = 108757787000274036210359376021024492
Position Router.executeDecreasePosition()
[afterUpdate] ShortsTracker.globalShortAveragePrices = 104766755156748843189540879601516878
随后的 TX 6-7,8-9,10-11,12-13 都是在重复 TX 4-5 的操作,其目的就是通过反复多次的操作尽可能地缩小
globalShortAveragePrices
的值
TX 6
355879337\]
-
(in reentrancy)
Vault.increasePosition()
: 绕过 enableLeverage 检查和 globalShortAveragePrices 的更新,直接创建 WBTC short position(抵押品为 2791 USDC)[positionKey = 0x255b] -
(in reentrancy)
Position Router.createDecreasePosition()
: 创建 WBTC short position 的平仓 request [requestKey = 0x1489, decreasePositionsIndex = 2]price = 109527370000000000000000000000000000
In ShortsTracker:
[before]ShortsTracker.globalShortAveragePrices = 104934381964999641338644145008879305
TX 7
355879359\]
TX 8
355879563\]
- (in reentrancy)
Vault.increasePosition()
: 绕过 enableLeverage 检查和 globalShortAveragePrices 的更新,直接创建 WBTC short position (抵押品为 2622 USDC) [positionKey = 0x255b] - (in reentrancy)
Position Router.createDecreasePosition()
: 创建 WBTC short position 的平仓 request [requestKey = 0xe63c, decreasePositionsIndex = 3]
TX 9
355879585\]
TX 10
355879763\]
- (in reentrancy)
Vault.increasePosition()
: 绕过 enableLeverage 检查和 globalShortAveragePrices 的更新,直接创建 WBTC short position (抵押品为 2481 USDC) [positionKey = 0x255b] - (in reentrancy)
Position Router.createDecreasePosition()
: 创建 WBTC short position 的平仓 request [requestKey = 0xcc53, decreasePositionsIndex = 4]
TX 11
355879785\]
TX 12
355879999\]
-
(in reentrancy)
Vault.increasePosition()
: 绕过 enableLeverage 检查和 globalShortAveragePrices 的更新,直接创建 WBTC short position (抵押品为 2345 USDC)[positionKey = 0x255b] -
(in reentrancy)
Position Router.createDecreasePosition()
: 创建 WBTC short position 的平仓 request [requestKey = 0xf42a, decreasePositionsIndex = 5]price = 109466220000000000000000000000000000
In ShortsTracker:
[before]ShortsTracker.globalShortAveragePrices = 9881613652623553707300056873939342
TX 13
Vault.decreasePosition()
: Keeper 关闭 WBTC short position,赎回 2182 USDCgmxPositionCallback()
: 在 Callback 函数中调用Order Book.createDecreaseOrder()
创建 WETH decrease order [positionKey = 0x255b, decreaseOrdersIndex = 5]
355880022\]
TX 1-13 的目的,都是通过利用重入漏洞,绕过 ShortsTracker.globalShortAveragePrices
的更新进行开仓,从而达到降低 ShortsTracker.globalShortAveragePrices
值的目的。
TX 14 (攻击交易)
355880237\]
取消质押 GLP,并以其他各种代币的形式进行提取。

- 以提取 WBTC 的调用为例,攻击者只移除了 386498 GLP,经过计算得出这部分的价值为 9731948 USDG,等价于 88 WBTC。

- WETH:移除 341596 GLP,赎回价值 8601309 USDG 的 3205 WETH
- USDC:移除 7503 GLP,赎回价值 188930 USDG 的 187343 USDC
- LINK:移除 13453 GLP,赎回价值 338759 USDG 的 23800 LINK
- UNI:移除 21422 GLP,赎回价值 539419 USDG 的 65479 UNI
- USDT:移除 53812 GLP,赎回价值 1354 USDG 的 1343 USDT
- FRAX:移除 450568 GLP,赎回价值 11345197 USDG 的 11249897 FRAX
- DAI:移除 53603 GLP,赎回价值 1349722 USDG 的 1338385 DAI
攻击者在这个环节中共赎回了 1328455 GLP,剩余 2801123 GLP
超额的赎回价值是如何计算出来的呢?
在计算赎回 GLP 获得的 WBTC 数量时,首先通过 _removeLiquidity()
计算等价的 USDG。其中 usdgAmount
的值需要根据 aumInUsdg
来计算,而 aumInUsdg
正是被攻击者所操控的值。

AUM 的含义及计算方法
Assets Under Management (AUM)
AUM 代表 GMX 协议管理的所有资产的总价值
用途: GLP价格 = AUM / GLP总供应量
getAum()
函数计算 GMX 协议管理的所有资产的总价值,分为稳定币和非稳定币两种计算方式。
https://github.com/gmx-io/gmx-contracts/blob/master/contracts/core/GlpManager.sol#L136
稳定币的资产总价值计算方式较为简单,代币数量 * 代币价格:poolAmount * price
非稳定币的资产总价值计算涉及以下方面:
-
空头仓位数量:size
-
空头仓位获利/亏损数量:delta
-
多头垫付资金:guaranteedUsd
guaranteedUsd = size - collateral
多头仓位收益/亏损 = size - guaranteedUsd
-
可用流动性:poolAmount - reservedAmount
计算公式:WBTC_AUM = guaranteedUsd + (poolAmount - reservedAmount) × price ± delta

其中 delta
通过 getGlobalShortDelta()
函数进行计算,其中 averagePrice
的值被攻击者通过 TX 1-13 的操控后,变得远小于实际值。使得最终计算得到的 delta
要远大于实际值。


globalShortAveragePrices = 1913705482286167437447414747675542(正常值的 1.76%)

delta:865836626141799337421744137507209211350
hasProfit
:False
由于 hasProfit
为 false,代表空头亏损,所以 WBTC_AUM
的计算公式需要加上被操控的 delta
。
WBTC_AUM = guaranteedUsd + (poolAmount - reservedAmount) × price + delta
这也就导致了 aumInUsdg 的值比正常情况下大,计算得到的 usdgAmount 值也变大,所以攻击者能够赎回获得超额的收益。
Vault.decreasePosition()
调用 Vault.decreasePosition()
关闭 WBTC short position,取回 1507796 USDC

Repeat to get more USDC
接下来黑客进行了 3 次操作去扩大收益,前面 2 次为了积累 GLP 代币,为了在第 3 次赎回超额的 USDC。

第 1 次操作质押 FRAX 获得了 16083241 GLP,赎回使用了 625160 GLP,剩余了 15458081 GLP。但同时又亏损了 149057 FRAX 和 2500 USDC。

(第 2 次操作与第 1 次类似)
第 3 次操作 tokenOut 选择的是 USDC,赎回得到 15834169 USDC

Repay flashloan
归还闪电贷

后记
这次的 GMX 攻击事件分析可以说是我分析过的较为复杂的攻击了(真的是看得身心疲惫啊),尤其是 GMX 里面涉及到了很多关于永续合约仓位和收益的计算。里面每个参数的含义,计算公式的含义还是比较难理解的。还有不得不说前面的 13 笔准备交易的收集也花费了大量的时间和精力,不过对 GMX 的了解也在理清楚准备交易的过程中慢慢加深了。托这次攻击事件的福,我也是把一直没看的 GMX 也过了一遍了,希望这篇文章也能够给你带来收获。