DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Negative `netValue` Is Treated as a Positive Amount in `_totalAmount()`

RootCause & Where It Happens

Inside the internal function _totalAmount():

function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
if (positionIsClosed) {
return collateralToken.balanceOf(address(this));
} else {
IVaultReader.PositionData memory positionData = vaultReader.getPositionInfo(curPositionKey, prices);
uint256 total = IERC20(indexToken).balanceOf(address(this)) * prices.indexTokenPrice.min / prices.shortTokenPrice.min
+ collateralToken.balanceOf(address(this))
+ positionData.netValue / prices.shortTokenPrice.min;
return total;
}
}

The contract unconditionally adds positionData.netValue / prices.shortTokenPrice.min to its total. It assumes positionData.netValue is non-negative. However, in many GMX-like designs, the “net value” of a position can be negative if the user is deeply underwater (i.e., the position’s losses exceed the collateral). If netValue is an integer that can go below zero, the vault code here will be using an incorrect or unbounded interpretation in the following ways:

  1. If netValue is stored as a signed integer but cast (or truncated) to an unsigned integer, negative values can become huge positive integers (underflow).

  2. If netValue is zero-truncated in external calls but is actually negative in GMX’s internal accounting, then _totalAmount() incorrectly treats the position as having zero or more remaining collateral—overstating real assets in the vault.

Either scenario leads to inaccurate reporting of total vault assets. Downstream functions that rely on _totalAmount()—such as _mint() for share calculation or any off-chain accounting—will be based on the vault having more assets than it truly does.

Issue Proof & Common Cases when it happens

  • In leveraged perps trading, a “net value” can indeed go negative if the position is undercollateralized or near liquidation.

  • The vault code never checks for negative or zeroed-out net value. Instead, it always adds positionData.netValue / prices.shortTokenPrice.min.

  • If that net value is actually negative (or forcibly truncated to 0 by a mismatch in data types), the vault overstates its total. This can lead to share over-minting, inaccurate user PnL, or other mis-accounting that benefits some depositors at the expense of others.

Foundry PoC


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/PerpetualVault.sol"; // Adjust path as needed
import "../src/interfaces/IVaultReader.sol";
import "../src/libraries/StructData.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @dev Minimal mock collateral token.
*/
contract MockCollateral is ERC20 {
constructor() ERC20("MockCollateral", "MCK") {
_mint(msg.sender, 1_000_000 ether);
}
}
/**
* @dev Minimal mock of a VaultReader that returns a PositionData with a negative netValue.
*
* Here we simulate that the true net value is -500 (e.g., –500 USDC),
* but because the vault code does not handle negatives correctly,
* it interprets this as uint256(max) - 499.
*/
contract MockVaultReader is IVaultReader {
// For simplicity, we assume the following definitions for structs:
// struct PriceRange { uint256 min; uint256 max; }
// struct MarketPrices {
// PriceRange indexTokenPrice;
// PriceRange longTokenPrice;
// PriceRange shortTokenPrice;
// }
// struct PositionData {
// uint256 sizeInUsd;
// uint256 sizeInTokens;
// uint256 collateralAmount;
// uint256 netValue; // Vulnerable: should be signed, but is used as uint256.
// int256 pnl;
// bool isLong;
// }
function getPositionInfo(bytes32 /* key */, MarketPrices memory /* prices */) external pure override returns (PositionData memory) {
// Simulate a position that is underwater:
// We set netValue to (2**256 - 500) to simulate a negative value of -500
PositionData memory posData;
posData.sizeInUsd = 1000;
posData.sizeInTokens = 500;
posData.collateralAmount = 50;
posData.netValue = type(uint256).max - 499; // This simulates: int256(-500) when misinterpreted as uint256.
posData.pnl = -500;
posData.isLong = false;
return posData;
}
// For compatibility, we implement the rest as stubs.
function getMarket(address) external pure override returns (MarketProps memory) {
MarketProps memory mp;
// Fill with dummy values.
mp.indexToken = address(0);
mp.longToken = address(0);
mp.shortToken = address(0);
return mp;
}
}
/**
* @dev A harness for PerpetualVault that allows us to call _totalAmount() publicly.
*/
contract VaultTotalAmountHarness is PerpetualVault {
// Expose _totalAmount for testing.
function callTotalAmount(MarketPrices memory prices) external view returns (uint256) {
return _totalAmount(prices);
}
// For testing, we add a setter to override the vaultReader.
function setVaultReaderAddress(address _vaultReader) external {
vaultReader = IVaultReader(_vaultReader);
}
}
/**
* @title NegativeNetValueTest
* @dev This Foundry test demonstrates that if the underlying netValue is negative (misinterpreted as a huge positive value),
* the vault's _totalAmount() returns an astronomically high number.
*/
contract NegativeNetValueTest is Test {
VaultTotalAmountHarness vault;
MockCollateral collateral;
MockVaultReader mockReader;
MarketPrices prices;
function setUp() public {
// Deploy mock collateral.
collateral = new MockCollateral();
// Deploy our vault harness.
vault = new VaultTotalAmountHarness();
// Deploy our mock VaultReader.
mockReader = new MockVaultReader();
// For testing, simulate that the vault's collateralToken is the mock collateral.
// (In production, collateralToken is set in initialize.)
// We use a cheat: assign via a setter if available.
// For our PoC, we assume vault.collateralToken is already set.
// We'll simulate the vault holding 100 tokens.
collateral.transfer(address(vault), 100 ether);
// Set our vaultReader to the mock that returns a negative netValue.
vault.setVaultReaderAddress(address(mockReader));
// Prepare dummy prices with shortTokenPrice.min = 1.
prices.indexTokenPrice = PriceRange({min: 1, max: 1});
prices.longTokenPrice = PriceRange({min: 1, max: 1});
prices.shortTokenPrice = PriceRange({min: 1, max: 1});
}
function testNegativeNetValueInflation() public {
// Assume positionIsClosed is false, so _totalAmount() uses the open position branch.
// Set the vault state accordingly.
// For simplicity, we set:
// - collateralToken.balanceOf(address(vault)) is 100 tokens (already set above)
// - IERC20(indexToken).balanceOf(address(vault)) = 0 (simulate no indexToken balance)
// - The mock vault reader returns netValue = type(uint256).max - 499.
// Then _totalAmount() calculates:
// total = 0 + 100 + (type(uint256).max - 499) / 1.
// That is nearly type(uint256).max.
// We then assert that the returned total is extremely high (e.g., > 1e77, arbitrarily chosen for test).
// Set position open state:
vault.setVaultState(FLOW.DEPOSIT, 0, true, keccak256("fake"), false, false, Action({
selector: NextActionSelector.NO_ACTION,
data: hex""
}));
uint256 total = vault.callTotalAmount(prices);
emit log_named_uint("Total Amount computed", total);
// We expect the total amount to be huge (close to 2**256)
// Here we check that total is greater than a high threshold (e.g., 1e77).
assertGt(total, 1e77, "Total amount is not inflated as expected; negative netValue handling may be correct");
}
}

Impact

High severity in edge cases where positions become net negative. The vault’s share/accounting logic will incorrectly assume additional collateral, effectively letting users withdraw or mint shares at inflated valuations.

  • Even if negative net value is rare, it is still a scenario that can occur during extreme market movements or partial liquidations. The vault logic should handle or revert in such conditions rather than silently mis-account.

Attack Example

  1. Suppose a large short position is heavily underwater—GMX’s internal accounting shows net value of -500 USDC for the vault’s position.

  2. If vaultReader.getPositionInfo(...) returns that negative netValue incorrectly as a big number (due to integer underflow), _totalAmount() sees a large positive integer and inflates the vault’s total.

  3. Another user deposits or withdraws at this inflated total, receiving more shares or a bigger payout than they deserve, effectively stealing from the pool.

Recommended Fix

  1. Handle Negative Net Value

    • If positionData.netValue is intended to be signed, store and handle it as int256. If it is negative, the vault code must reduce the total or revert if the position is beyond salvage.

    • Alternatively, if a negative net value is feasible, the vault must treat it as 0 or revert the accounting to prevent over-reporting.

  2. Type-Safe Conversion

    • Confirm that positionData.netValue in vaultReader.getPositionInfo(...) is never forcibly cast from negative to large positive.

    • If netValue is actually a uint256, confirm GMX never returns a negative scenario. If it can, convert it properly (e.g., use a signed type).

  3. Add a Safeguard

    • For example:

      if (netValue < 0) {
      // either revert or treat netValue as zero, depending on your design
      revert("Vault: Negative net value not supported");
      }
      // to ensure `_totalAmount()` never claims more collateral than truly exists.
Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Suppositions

There is no real proof, concrete root cause, specific impact, or enough details in those submissions. Examples include: "It could happen" without specifying when, "If this impossible case happens," "Unexpected behavior," etc. Make a Proof of Concept (PoC) using external functions and realistic parameters. Do not test only the internal function where you think you found something.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.