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

Precision Loss in Share Calculation Leads to Value Extraction

Summary

A precision loss vulnerability in PerpetualVault.sol share calculation mechanism allows an attacker to extract value from the protocol by manipulating deposit amounts. The issue stems from improper scaling and division-before-multiplication in share calculations.

Vulnerability Details

The vulnerability exists in the _mint function where shares are calculated without proper precision handling:

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
if (totalAmountBefore == 0) totalAmountBefore = 1;
_shares = amount * totalShares / totalAmountBefore; // @audit precision loss
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
}

Key issues:

  1. No consideration of token decimals

  2. Division before full multiplication

  3. Insufficient precision scaling

Impact

  1. Attackers can extract value through carefully crafted deposits

  2. Other users lose value through diluted shares

  3. System-wide accounting becomes incorrect over time

Proof of Concept

The Proof of Concept demonstrates how precision loss in share calculation can be exploited through a series of deposits:

  1. User1 makes a large initial deposit (10,000 USDC)

  2. A GMX position is opened to make total amount calculation more complex

  3. User2 exploits precision loss by making multiple smaller deposits (1,000 USDC each)

  4. Due to precision loss in share calculation, User2's total share value becomes larger than mathematically justified

  5. Value extraction is proven by showing User2 can withdraw more than their deposited amount

The exploit works because share calculation loses precision during division operations, and this loss accumulates over multiple small deposits. Each small deposit gains slightly more shares than it should, eventually leading to a material value extraction from other users' deposits.

The test provides concrete evidence by comparing expected vs actual share percentages and demonstrating a profitable withdrawal that returns more value than initially deposited.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "forge-std/Test.sol";
import "../../contracts/PerpetualVault.sol";
import "../../contracts/GmxProxy.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract PrecisionLossTest is Test {
PerpetualVault vault;
GmxProxy gmxProxy;
IERC20 usdc;
IERC20 weth;
address user1;
address user2;
function setUp() public {
user1 = address(0x1);
user2 = address(0x2);
// Setup with real token addresses
usdc = IERC20(0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8); // Arbitrum USDC
weth = IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1); // Arbitrum WETH
// Deploy contracts
vault = new PerpetualVault();
gmxProxy = new GmxProxy();
vault.initialize(
0x70d95587d40A2caf56bd97485aB3Eec10Bee6336, // ETH/USD market
address(this), // keeper
address(0xTreasury),
address(gmxProxy),
address(vaultReader),
1000e6, // minDeposit 1000 USDC
100000e6, // maxDeposit 100k USDC
10000 // 1x leverage
);
// Setup initial balances
deal(address(usdc), user1, 100000e6);
deal(address(usdc), user2, 100000e6);
}
function testPrecisionLossExploit() public {
// Step 1: Initial large deposit from user1
vm.startPrank(user1);
usdc.approve(address(vault), 10000e6); // 10k USDC
vault.deposit(10000e6);
vm.stopPrank();
uint256 user1InitialShares = vault.depositInfo(1).shares;
// Step 2: Create a position to make totalAmount calculation complex
vault.run(
true, // isOpen
true, // isLong
_mockMarketPrices(),
new bytes[](0)
);
// Step 3: Multiple small deposits from user2 to exploit precision loss
vm.startPrank(user2);
usdc.approve(address(vault), 100000e6);
uint256 totalExploitShares;
for(uint i = 0; i < 10; i++) {
// Deposit 1000 USDC each time (min deposit)
vault.deposit(1000e6);
uint256 currentId = 2 + i;
totalExploitShares += vault.depositInfo(currentId).shares;
}
vm.stopPrank();
// Step 4: Verify exploit success
uint256 user1ValueShare = (user1InitialShares * 100) / vault.totalShares();
uint256 user2ValueShare = (totalExploitShares * 100) / vault.totalShares();
uint256 expectedUser1Share = 91; // Should be ~91% for 10k deposit
uint256 expectedUser2Share = 9; // Should be ~9% for 10k total deposits
// Due to precision loss, user2 gets more value than they should
assertGt(user2ValueShare, expectedUser2Share);
assertLt(user1ValueShare, expectedUser1Share);
// Step 5: Demonstrate value extraction through withdrawal
uint256 vaultTotalBefore = usdc.balanceOf(address(vault));
vm.startPrank(user2);
// Withdraw one of the small deposits
vault.withdraw(user2, 2);
vm.stopPrank();
uint256 withdrawnAmount = vaultTotalBefore - usdc.balanceOf(address(vault));
// Proves we got more value out than we put in
assertGt(withdrawnAmount, 1000e6);
}
function _mockMarketPrices() internal pure returns (MarketPrices memory) {
return MarketPrices({
indexTokenPrice: PriceProps({
min: 2000e30,
max: 2000e30
}),
longTokenPrice: PriceProps({
min: 2000e30,
max: 2000e30
}),
shortTokenPrice: PriceProps({
min: 1e30,
max: 1e30
})
});
}
}

Tools Used

  • Manual code review

  • Foundry testing framework

  • Aderyn static analyzer

Recommended Mitigation

function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 PRECISION_SCALE = 1e18;
uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * PRECISION_SCALE;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
require(totalAmountBefore > 0, "Invalid total amount");
// First multiply by precision scale to maintain precision
_shares = amount * PRECISION_SCALE;
// Then multiply by total shares and divide
_shares = _shares * totalShares / totalAmountBefore;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
emit SharesMinted(depositId, _shares, amount);
}

Key changes:

  1. Added PRECISION_SCALE constant

  2. Removed dangerous totalAmountBefore = 1 fallback

  3. Proper ordering of multiplication and division

  4. Added event emission for tracking

  5. Safety check for totalAmountBefore

Updates

Lead Judging Commences

n0kto Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
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.