If a user is withdrawing and lets say the position opened on GMX got partially ADL/liquidated it might send the perp vault index tokens , to account for these index tokens the contract perform a swap of index token to collateral tokens and hence user's would get the correct amount of collateral when withdrawing , but we will see how the swap condition is improper which would lead to user's not getting profits out of these index tokens.
Consider the following ->
1.) There is an active perp vault position on GMX with leverage > 1x.
2.) A user has requested a withdraw via withdraw()
->
the flow is assigned as WITHDRAW at L255 , and since if (curPositionKey != bytes32(0))
(cause a position is open on GMX with leverage > 1x) ->
And , next action is WITHDRAW_ACTION and _settle()
is called.
3.) Inside _settle()
a settle order is created (routed through GmxProxy.sol) ->
[https://github.com/CodeHawks-Contests/2025-02-gamma/blob/main/contracts/PerpetualVault.sol#L964]
4.) After a successful settle order , afterOrderExecution()
would be invoked by GmxProxy.sol and nextAction would be assigned as WITHDRAW_ACTION
->
4.) Now , the position got deleveraged (assume ADL auto deleveraged the position , sending collateral and index token to perp vault , see L249-L257 of GmxProxy.sol).
Lets say this ADL sent back perp vault 0.9 WBTC i.e. ~90k USD (We can also assume 0.9 ETH but WBTC is also accepted token and this makes the attack more impactful , even with WETH the impact is same , also 0.9 is also just for example to depict the issue).
Due to this afterLiquidationExecution()
would be triggered ->
This will assign nextAction to WITHDRAW_ACTION
(flow is WITHDRAW currently).
5.) Then keeper would invoke runNextAction()
and since nextAction is WITHDRAW_ACTION
, this branch would be invoked (L371-L381)->
We see that if there is index token the function will try to swap it to collateral token BUT the condition is that ->
This means , there should be atleast one WBTC to make a swap else don't swap (prices is in (30 - tokenDecimals) therefore condition means there should be an entire index token to make a swap , i.e an entire WBTC and not just 1 wei of WBTC).
6.) Since there is not a complete WBTC (we have 0.9 WBTC) we will skip the swap and move to _withdraw()
(L1104-L1118) ->
We calculate based on user's shares how much collateral to remove and then account for fee and pass it to _createDecreasePosition()
which would create a Market Decrease order at L936 , and when executed calls afterOrderExecution()
where ->
We would store the prevCollateralBalance
(just to track how much collateral was received after decrease market order) and then nextAction is Assigned as FINALIZED , keeper runs runNextAction()
and this branch would trigger (L390-L397) ->
7.) We would again skip the swap condition since there is just 0.9 WBTC and call finalize with the data (remember in the data there is prevCollateralAmount which is just collateral amount in perp vault before the collateral from decrease market order was received) ->
withdrawn
amount is what we received from market decrease and then we call _handleReturn()
->
8.) The amount sent to the user is the amount got back from the market decrease and if there is collateral token in the vault he will receive the collateral token amount based on the shares , but as we can see we did not account for the 0.9 WBTC received , this 0.9 WBTC will not be accounted for any further withdrawal (unless additional 0.1 WBTC is received in future) and this amount is , for that span of time , stuck in the perp vault.
The impact would be have been similar for partial deleverages by ADL , in that case say 0.5 WBTC was received by the perp vault even then the withdraw action would not account for the 0.5 WBTC with the reasoning similar to the above , with just one difference that in partial ADL afterLiquidationExecution()
would not be invoked by GmxProxy.sol (L256-L257)
The output amount does not account for the 0.9 WBTC (or even if it was WETH) sent to the perp vault contract , this amount due to partial liquidation or partial ADL should be accounted for since it was sent after partially closing the same position we withdrew from , this is direct loss of funds for the withdrawers. The reason is we require that a minimum of 1 entire index token should be present which is a very strict check and as explained leads to loss of funds for users and those 0.9 WBTC stuck in the vault.
Manual analysis
Make the swap condition less strict or refactor the swap logic.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.