today i go over a hypothetically profitable strategy that runs an oracle manipulation attack on GMX.
for my previous two bots, cointbot and cowsol, i provided a complete and original production-level strategy and source-code. for this one, i will only supply the idea.
this vulnerability is not new, and whoever is following the space closely is probably pretty aware of it. last year, there was some conversation about adding a twamm mechanism to the fast price feed (i will tell you what this feed is below).
i will leave it open to you to verify whether this strategy works (for example, by simulating against past data).
market manipulation is illegal but openly talking about risks is healthy and helps the space to progress (in any case, of course i am not responsible for anything you do with my free code or ideas).
“gmx offers zero slippage on trades via an oracle price update system (chainlink + aggregate of prices from leading volume exchanges), while amms rely on arb bots to balance prices in the pools”
gmx is a decentralized spot and perpetual futures exchange built on arbitrum and avalanche chains.
it exploded after the ftx fall, and just this week, it seems to have become the top defi project on fee profit, and one of the top projects by fee and revenue in defillama. they are winning by offering perks such as no slippage, low swap fees, and zero price impact trades (i.e., large trades are set at the mark price).
gmx’s swap trades are performed based on a combination of two oracles:
the primary oracle is fed by chainlink, and it takes the last three samples (picking the worst price for the user).
the secondary oracle is the “fast price feed”, which overrides the price whenever it is within 2.5% of the primary oracle price value.
gmx enables traders to open up to 50x leverage swaps for long or short positions by borrowing from glp, a multi-asset pool containing $btc, $eth, $uni, $link, and stablecoins. the vault allows swapping of the tokens it holds.
funds are deposited into the vault by minting glp tokens and can be withdrawn by burning these tokens.
glp works as the counterparty in the protocol, as it accrues values when traders loses, and devalues when traders win.
glp is also emerging as a form of collateral, with lending protocols integrating this liquidity provider token into their product offerings (e.g., rage, unami, sentiment).
gmx’s native token, $gmx, functions as a governance, utility, and value-accrual token. glp accrues 70% of all trading fees, while stakers of gmx earn 30%.
a floor price fund helps ensure liquidity in the glp pool, plus a reliable stream of $eth rewards for $gmx stakers.
the protocol's revenues come from swap fees, trading fees, execution fees, liquidation fees, and borrow fees.
fees are set in the vault’s contract:
gmx’s keepers can liquidate a position if the position reduces to a point at which the collateral minus losses minus borrow fee becomes less than 1% of position's size.
the gmx protocol is composed of the following parts:
the vault contracts handle trading functions and deposits of usd to glp (named usdg). it includes the price feeds contracts, which handle the two price feed oracles.
the router contract implements convenient functions on top of the vault. it’s also used to help mitigate frontrunning attacks through a two-step transaction process.
the gmx governance contracts and the glp liquidity provider contracts, are regular erc20 tokens utilized by the protocol.
for the strategy, we will be looking at the first.
the main vault contract is Vault.sol
(on arbitrum and avalanche) and is used to handle gmx’s trading functions.
here is a list of the external and public methods:
swap(address _tokenIn, address _tokenOut, address _receiver)
getMaxPrice(_token)
getMinPrice(_token)
buyUSDG(address _token, address _receiver)
sellUSDG(address _token, address _receiver)
directPoolDeposit(address _token)
increasePosition(address _account, address _collateralToken, address _indexToken, uint256 _sizeDelta, bool _isLong)
decreasePosition(address _account, address _collateralToken, address _indexToken, uint256 _collateralDelta, uint256 _sizeDelta, bool _isLong, address _receiver)
liquidatePosition(address _account, address _collateralToken, address _indexToken, bool _isLong, address _feeReceiver)
validateLiquidation( _account, _collateralToken, _indexToken, _isLong, _raise)
getRedemptionAmount(_token, _usdgAmount)
getRedemptionCollateral(_token)
getRedemptionCollateralUsd(_token)
tokenToUsdMin(_token, _tokenAmount)
usdToTokenMax(_token, _usdAmount)
usdToTokenMin(_token, _usdAmount)
usdToToken(_token, _usdAmount, _price)
getPosition(_account, _collateralToken, _indexToken, _isLong)
getPositionLeverage(_account, _collateralToken, _indexToken, _isLong)
getUtilisation(_token)
getNextAveragePrice(_indexToken, _size, _averagePrice, _isLong, _nextPrice, _sizeDelta, _lastIncreasedTime)
getNextGlobalShortAveragePrice(_indexToken, _nextPrice, _sizeDelta)
getGlobalShortDelta(_token)
getPositionDelta(_account, _collateralToken, _indexToken, _isLong)
getDelta(_indexToken, _size, _averagePrice, _isLong, _lastIncreasedTime)
getEntryFundingRate(_collateralToken, _indexToken, _isLong)
getFundingFee(_account, _collateralToken, _indexToken, _isLong, _size, _entryFundingRate)
getPositionFee(_account, _collateralToken, _indexToken, _isLong, _sizeDelta)
getTargetUsdgAmount(_token)
the one we are interested in is swap()
.
❓how does swapping asset work
❓how does it know _tokenIn
and _tokenOut
prices, so it can send the correct amount to _receiver
assets are swapped by the prices given by priceIn
calling getMinPrice(_tokenin)
and priceOut
calling getMaxPrice(_tokenOut)
:
nice, we are hitting the price methods.
remember that the price feed contracts implement a primary price mechanism that samples the three last chainlink oracle values and picks the worst for the trade. from gmx’s docs:
The vault uses the price from the keeper if it is within a configured percentage of the corresponding Chainlink price. If the price exceeds this threshold, then a spread would be created between the bounded price and the Chainlink price, this threshold is based on the historical max deviation of the Chainlink price from the median price of reference exchanges. For example, if the max deviation is 2.5% and the price of the token on Chainlink is $100, if the keeper price is $103, then the pricing on the vault would be $100 to $103.
so, this primary price mechanism is overridden when the secondary (fast feed) is enabled and the price is within a 2.5% range. this also gives a different price depending on the direction (it has lower chances of being > than an amm).
let’s check how the snippet above calls getPrice(),
which is implemented by the vault contract for the price feed, VaultPriceFeed.sol
:
we see two options to get the asset’s price.
when useV2Pricing()
is set to false
(like here), getPriceV2()
is fired:
here is where the fun starts.
while the first price mechanism, getPrimaryPrice()
simply calls chainlink:
isAmmEnabled is usually set to true
(it’s false
in liquidation cases), so gmx’s prices are pretty much coming from getAmmPriceV2()
and then getAmmPrice()
:
yes, this is our good ol’ x*y=k price amm,
this amm could be vulnerable to manipulating markets. in other words, updates of gmx’s secondary oracle could, in theory, be sandwiched.
because of the way swap()
is called and the fact that gmx does not implement slippage, many arbs between gmx and secondary markets could be done until they are at the same price (or until a particular delta market is obtained).
💡 In the context of trading, delta (Δ) is a risk metric that estimates the change in the price of a derivative, given a $1 change in its underlying security. The delta also tells the hedging ratio to become delta neutral (more).
in theory, a secondary market with enough liquidity could allow our manipoolator to drain all the liquidity of gmx (well, proportional to the slippage of this secondary market):
gain = gmx_price / (secondary_market_price * gmx_liquidity)
now think about that 50x leverage. it could mean 150% gain on the initial position.
arbitrum runs on a centralized and ill-documented sequencers, so it does not have a public mempool. to get the right block timing, our manipoolator would have to run a strategy based on trial & error (while praying for fss not to be implemented soon).
but a sandwich attack could be possible on avalanche (here is a cool mev story on avalanche c-chain).
manipoolator would take advantage of pricing updates for the assets in the glp vault. it could search the mempool for large updates through txs containing SetPrices()
or when the fast price feed is updated (e.g., when keepers send txs to execute it).
(btw, PriceFeed.sol is the contract that accepts submissions from the price feed keeper, using the median price of binance, bitfinex, and coinbase)
at that point, manipoolator could
frontrun the tx to execute the order with a flashloan, buying an asset before the prices increases,
close its position right after the price change, and
profit the delta (after paying the opening-position-margin-funding-rate-closing-position-margin fees).
something like this:
🤔 we are not even talking about multiple pools or cross-chain yet… infinite possibilities?
🤔 would increasing trading fees lower the chances of an oracle manipulation attack (at least during large volatility)? but then, would gmx stop “winning”?
🤔 would trying to prevent blocks with more than two SetPrices()
txs help avoid sandwich attacks? yeah, but how?
🤔 or, in a different direction, what if the prices of the secondary markets could be manipulated to make them equal to the primary oracle, undermining the second oracle? what would that mean?
🤔 would manipoolator try to remain stealthy, or would it go all in during a volatile event?
there have been some reports of exploitation of the gmx’s oracles last year, with somehow similar ideas described in this post.
one of the shenanigans was that while the gmx team runs keepers to update prices by making calls to SetPriceWithBit()
, mev operators could observe these price updates in the mempool before they are on-chain.
on another note, during a major update regarding synths, gmx added a new oracle called xget with frontrunning protection: