Unsafe handling of over-100% early exit fees
Description
During a vault's earning period, a fixed depositor can call withdraw()
to withdraw their stake and back out of the vault. If they do so, after the withdraw is finalized and they call finalizeVaultOngoingFixedWithdrawals()
to claim their ETH, they are charged an early exit fee that depends on the timestamp they exited.
This early exit fee is calculated in the function claimFixedVaultOngoingWithdrawal
, which returns the ETH amount ultimately transferred to the user
:
function claimFixedVaultOngoingWithdrawal(address user) internal returns (uint256) {
if (user == address(0)) return 0;
WithdrawalRequest memory request = fixedToVaultOngoingWithdrawalRequestIds[user];
uint256[] memory requestIds = request.requestIds;
require(requestIds.length != 0, "WNR");
uint256 upfrontPremium = userToFixedUpfrontPremium[user];
delete userToFixedUpfrontPremium[user];
delete fixedToVaultOngoingWithdrawalRequestIds[user];
uint256 arrayLength = fixedOngoingWithdrawalUsers.length;
for (uint i = 0; i < arrayLength; i++) {
if (fixedOngoingWithdrawalUsers[i] == user) {
delete fixedOngoingWithdrawalUsers[i];
}
}
uint256 amountWithdrawn = claimWithdrawals(msg.sender, requestIds);
uint256 earlyExitFees = calculateFixedEarlyExitFees(upfrontPremium, request.timestamp);
// add earlyExitFees to earnings for variable side
feeEarnings += earlyExitFees
emit LidoWithdrawalFinalized(user, requestIds, FIXED, isStarted(), isEnded());
return amountWithdrawn - Math.min(earlyExitFees, amountWithdrawn);
}
Note that according to the documentation, the early exit fee starts at 110% of their deposited stake.
The feeEarnings
variable, when increased, directly represents fees able to be withdrawn by the variable-side depositors. Because of the Math.min(earlyExitFees, amountWithdrawn)
, this fee is given to the variable-side depositors even when the fee cannot be removed, due to lack of funds, from the amount withdrawn by the fixed-side caller.
Impact
If the Math.min
call above happens while earlyExitFees > amountWithdrawn
, the difference can be claimed by the variable side even though no portion of the contract's actual balance includes it. In normal operation, this means that the last few variable-side users to claim the fee will not be able to claim it until more ETH enters the contract.
This issue creates a significant perverse economic exploit. If the first user to deposit into the vault is a fixed depositor who does not fill up most of the fixed space, then an attacker can fill up the entire remaining fixed and variable space and then immediately withdraw all of their fixed deposits. Since the attacker owns the entire variable space, they are entitled to all the fees that their fixed holdings were charged, and since zero time has passed, there will be no protocol fees for their fixed withdrawal. So, if the attacker claims their fixed ongoing withdrawal immediately after the vault ends, the extra 10% of the attacker's large fixed position is paid for by the victim's smaller fixed position, and the victim cannot withdraw due to the contract running out of native ETH.
Recommendations
If the early exit fees exceed the amount withdrawn, then the entire amount withdrawn should be added to feeEarnings
instead of an amount that exceeds it.
Alternatively, if the suggestion in Discussion ref↗ is implemented, then this would not be an issue since the early exit fees would always be less than the amount withdrawn, both denominated in stETH.
Remediation
This issue has been acknowledged by Saffron, and a fix was implemented in commit a9cc64b4↗.