Summary
PerpetualVault
has an issue in calculating shares when the vault is almost empty, which allows the second depositor to get a disproportionate amount of shares compared to the first depositor.
Vulnerability Details
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;
}
depositInfo[depositId].shares = _shares;
totalShares = totalShares + _shares;
}
In the _mint
function, when totalShares == 0
, shares are calculated by depositInfo[depositId].amount * 1e8
and for subsequent deposits, shares are calculated based on the proportion amount * totalShares / totalAmountBefore
.
When totalAmountBefore = 0
, the code uses the value 1 as a fallback, this causes the share calculation to be very inaccurate because dividing by 1 will result in a large value where users who deposit after totalAmount
approaches 0 will get a much larger share.
Impact
Bob got more shares than he should have.
Scenario
1. Alice makes the first deposit:
2. There is a big loss due to the market crash:
3. Bob makes a deposit:
Deposit: 1000 USDC
Shares calculation: 1000 * 100,000,000,000 / 1
Bob gets more shares than he should
POC
Add this to PerpetualVault.t.sol
and run it forge test --match-test test_ShareCalculationExploit --rpc-url arbitrum -vvvv
.
function test_ShareCalculationExploit() external {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 depositAmount = 1000e6;
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(alice);
deal(address(collateralToken), alice, depositAmount);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, depositAmount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(depositAmount);
vm.stopPrank();
uint256[] memory aliceDeposits = PerpetualVault(vault).getUserDeposits(alice);
(, uint256 aliceShares,,,,) = PerpetualVault(vault).depositInfo(aliceDeposits[0]);
assertEq(aliceShares, depositAmount * 1e8);
uint256 vaultBalance = collateralToken.balanceOf(vault);
uint256 crashedBalance = 1;
uint256 transferAmount = vaultBalance - crashedBalance;
vm.prank(vault);
collateralToken.transfer(address(0xdead), transferAmount);
vm.startPrank(bob);
deal(address(collateralToken), bob, depositAmount);
collateralToken.approve(vault, depositAmount);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(depositAmount);
vm.stopPrank();
uint256[] memory bobDeposits = PerpetualVault(vault).getUserDeposits(bob);
(, uint256 bobShares,,,,) = PerpetualVault(vault).depositInfo(bobDeposits[0]);
assertTrue(bobShares > aliceShares);
emit log_named_uint("Alice Shares", aliceShares);
emit log_named_uint("Bob Shares", bobShares);
uint256 totalShares = PerpetualVault(vault).totalShares();
uint256 alicePercentage = (aliceShares * 10000) / totalShares;
uint256 bobPercentage = (bobShares * 10000) / totalShares;
emit log_named_uint("Alice Share Percentage (bps)", alicePercentage);
emit log_named_uint("Bob Share Percentage (bps)", bobPercentage);
assertTrue(bobPercentage > 9900);
assertTrue(alicePercentage < 100);
}
Result:
├─ emit Minted(depositId: 1, depositor: alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], shareAmount: 100000000000000000 [1e17], depositAmount: 1000000000 [1e9])
Alice initial deposit and Alice gets 1e17 shares for a 1000 USDC deposit.
├─ [10163] 0xaf88d065e77c8cC2239327C5EDb3A432268e5831::transfer(0x000000000000000000000000000000000000dEaD, 999999999 [9.999e8])
Market crash simulation.
├─ emit Minted(depositId: 2, depositor: bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e], shareAmount: 100000000000000000000000000 [1e26], depositAmount: 1000000000 [1e9])
Bob deposits and Bob gets 1e26 shares for the same deposit of 1000 USDC.
├─ emit log_named_uint(key: "Alice Share Percentage (bps)", val: 0)
├─ emit log_named_uint(key: "Bob Share Percentage (bps)", val: 9999)
Comparison of final ownership percentages where Alice only owns ~0% of the total shares and Bob owns ~99.99% of the total shares.
Logs:
Alice Shares: 100000000000000000
Bob Shares: 100000000000000000000000000
Alice Share Percentage (bps): 0
Bob Share Percentage (bps): 9999
Tools Used
Recommendations
Add minimum threshold and validate shares ratio.
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;
}
uint256 MIN_TOTAL_AMOUNT = 1e6;
if (totalAmountBefore < MIN_TOTAL_AMOUNT) {
revert Error.InsufficientVaultBalance();
}
_shares = amount * totalShares / totalAmountBefore;
uint256 shareRatio = (_shares * 10000) / totalShares;
if (shareRatio > 9000) {
revert Error.ExcessiveShareAllocation();
}
}
}