Summary
Because Gamma create and manage GMX positions, it also uses GMX price functions to estimate assets values on GMX. GMX returns a min and max value when requesting a price.
When a user withdraws from the vault and the position has a negative PnL (loss), the function uses prices.shortTokenPrice.max to convert the loss amount from USD to token terms. This is incorrect as it should use prices.shortTokenPrice.min for conservatism in loss scenarios.
Using shortTokenPrice.max results in a smaller token amount subtracted from the withdrawal collateral (since dividing by a larger number yields a smaller result). This leads to users being able to withdraw more collateral than they should, effectively allowing them to avoid bearing their full share of losses.
Vulnerability details
When a withdrawal is initiated from the vault via the _withdraw function, the amount of collateral that can be withdrawn by a user is calculated based on their proportional share of the vault. If the vault's position has a negative PnL (a loss), this loss should reduce the amount of collateral the user can withdraw.
The issue occurs on L1112, where the negative PnL adjustment is calculated, and the problem is also present in the fee calculation on L1109:
File: contracts/PerpetualVault.sol
1089: function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
1090: uint256 shares = depositInfo[depositId].shares;
1091: if (shares == 0) {
1092: revert Error.ZeroValue();
1093: }
1094:
1095: if (positionIsClosed) {
1096: _handleReturn(0, true, false);
1097: } else if (_isLongOneLeverage(beenLong)) {
1098: uint256 swapAmount = IERC20(indexToken).balanceOf(address(this)) * shares / totalShares;
1099: nextAction.selector = NextActionSelector.SWAP_ACTION;
1100:
1101: nextAction.data = abi.encode(swapAmount, false);
1102: } else if (curPositionKey == bytes32(0)) {
1103: _handleReturn(0, true, false);
1104: } else {
1105: IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
1106: uint256 collateralDeltaAmount = positionData.collateralAmount * shares / totalShares;
1107: uint256 sizeDeltaInUsd = positionData.sizeInUsd * shares / totalShares;
1108:
1109: uint256 feeAmount = vaultReader.getPositionFeeUsd(market, sizeDeltaInUsd, false) / prices.shortTokenPrice.max;
1110: int256 pnl = vaultReader.getPnl(curPositionKey, prices, sizeDeltaInUsd);
1111: if (pnl < 0) {
1112: collateralDeltaAmount = collateralDeltaAmount - feeAmount - uint256(-pnl) / prices.shortTokenPrice.max;
1113: } else {
1114: collateralDeltaAmount = collateralDeltaAmount - feeAmount;
1115: }
1116: uint256 acceptablePrice = abi.decode(metadata, (uint256));
1117: _createDecreasePosition(collateralDeltaAmount, sizeDeltaInUsd, beenLong, acceptablePrice, prices);
1118: }
1119: }
In both cases, the contract uses prices.shortTokenPrice.max as the divisor when converting USD amounts to token amounts. This is fundamentally incorrect for a loss scenario:
When calculating losses, the contract should use the minimum token price for denominators (prices.shortTokenPrice.min) to ensure a more conservative estimation of the token equivalent of the loss.
Using the maximum price (prices.shortTokenPrice.max) results in a smaller token amount being deducted from the user's withdrawal.
Impact
Users withdrawing when the position has a negative PnL will get more tokens that they should receive.
Likelihood: high, as this happens on every withdrawal when the position has a negative PnL
Impact: medium, as this impacts all users' funds and creates perverse economic incentives
Recommended Mitigation Steps
Change the calculations to use prices.shortTokenPrice.min for both the fee and negative PnL adjustments:
function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
uint256 shares = depositInfo[depositId].shares;
if (shares == 0) {
revert Error.ZeroValue();
}
if (positionIsClosed) {
_handleReturn(0, true, false);
} else if (_isLongOneLeverage(beenLong)) {
uint256 swapAmount = IERC20(indexToken).balanceOf(address(this)) * shares / totalShares;
nextAction.selector = NextActionSelector.SWAP_ACTION;
// abi.encode(swapAmount, swapDirection): if swap direction is true, swap collateralToken to indexToken
nextAction.data = abi.encode(swapAmount, false);
} else if (curPositionKey == bytes32(0)) { // vault liquidated
_handleReturn(0, true, false);
} else {
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
uint256 collateralDeltaAmount = positionData.collateralAmount * shares / totalShares;
uint256 sizeDeltaInUsd = positionData.sizeInUsd * shares / totalShares;
// we always charge the position fee of negative price impact case.
- uint256 feeAmount = vaultReader.getPositionFeeUsd(market, sizeDeltaInUsd, false) / prices.shortTokenPrice.max;
+ uint256 feeAmount = vaultReader.getPositionFeeUsd(market, sizeDeltaInUsd, false) / prices.shortTokenPrice.min;
int256 pnl = vaultReader.getPnl(curPositionKey, prices, sizeDeltaInUsd);
if (pnl < 0) {
- collateralDeltaAmount = collateralDeltaAmount - feeAmount - uint256(-pnl) / prices.shortTokenPrice.max;
+ collateralDeltaAmount = collateralDeltaAmount - feeAmount - uint256(-pnl) / prices.shortTokenPrice.min;
} else {
collateralDeltaAmount = collateralDeltaAmount - feeAmount;
}
uint256 acceptablePrice = abi.decode(metadata, (uint256));
_createDecreasePosition(collateralDeltaAmount, sizeDeltaInUsd, beenLong, acceptablePrice, prices);
}
}
This change ensures that the contract uses the most conservative price when accounting for losses, which correctly protects the vault and remaining users from excessive withdrawals.