-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Tangy Menthol Tapir
Medium
Attacker/partial liquidator can extend Liquidation action by resetting $.liquidationStart[_agent] to 0.
Summary
Attackers can extend Liquidation by Action, by partially liquidating a position with a few wei to manipulate health to >=1e27 before taking the collateral, since the account is still liquidatable after the delegator is slashed of this amount, a new liquidator's call to liquidate this position will revert incorrectly. This is because the health check to close the liquidation uses debt amount after repayment with delegation still yet unslashed.
Root Cause
/// @notice Liquidate an agent when their health is below 1
/// @dev Liquidation must be opened first and the grace period must have passed. Liquidation
/// bonus linearly increases, once grace period has ended, up to the cap at expiry.
/// All health factors, LTV ratios, and thresholds are in ray (1e27)
/// @param $ Lender storage
/// @param params Parameters to liquidate an agent
/// @return liquidatedValue Value of the liquidation returned to the liquidator
function liquidate(ILender.LenderStorage storage $, ILender.RepayParams memory params)
external
returns (uint256 liquidatedValue)
{
(uint256 totalDelegation, uint256 totalSlashableCollateral, uint256 totalDebt,,, uint256 health) =
ViewLogic.agent($, params.agent);
ValidationLogic.validateLiquidation(
health,
totalDelegation * $.emergencyLiquidationThreshold / totalDebt,
$.liquidationStart[params.agent],
$.grace,
$.expiry
);
(uint256 assetPrice,) = IOracle($.oracle).getPrice(params.asset);
uint256 bonus = ViewLogic.bonus($, params.agent);
uint256 maxLiquidation = ViewLogic.maxLiquidatable($, params.agent, params.asset);
uint256 liquidated = params.amount > maxLiquidation ? maxLiquidation : params.amount;
@audit>> liquidated = BorrowLogic.repay(
$,
ILender.RepayParams({ agent: params.agent, asset: params.asset, amount: liquidated, caller: params.caller })
);
@audit>> (,,,,, health) = ViewLogic.agent($, params.agent);
@audit>> if (health >= 1e27) _closeLiquidation($, params.agent); // premature close ............. health is not health.....
liquidatedValue =
(liquidated + (liquidated * bonus / 1e27)) * assetPrice / (10 ** $.reservesData[params.asset].decimals);
if (totalSlashableCollateral < liquidatedValue) liquidatedValue = totalSlashableCollateral;
if (liquidatedValue > 0) IDelegation($.delegation).slash(params.agent, params.caller, liquidatedValue);
emit Liquidate(params.agent, params.caller, params.asset, liquidated, liquidatedValue);
}
Liquidation is closed before delegations are slashed, hence the health factor returned is not the ending health factor.....
/// @dev Cancel further liquidations with no checks
/// @param $ Lender storage
/// @param _agent Agent address
function _closeLiquidation(ILender.LenderStorage storage $, address _agent) internal {
@audit>> $.liquidationStart[_agent] = 0;
emit CloseLiquidation(_agent);
}
This will cause the the next liquidator or actual liquidator that was front run , the liquidator's call will revert here.
function liquidate(ILender.LenderStorage storage $, ILender.RepayParams memory params)
external
returns (uint256 liquidatedValue)
{
(uint256 totalDelegation, uint256 totalSlashableCollateral, uint256 totalDebt,,, uint256 health) =
ViewLogic.agent($, params.agent);
@audit>> ValidationLogic.validateLiquidation(
health,
totalDelegation * $.emergencyLiquidationThreshold / totalDebt,
$.liquidationStart[params.agent],
$.grace,
$.expiry
); /// @notice Validate the liquidation of an agent
/// @dev Health of above 1e27 is healthy, below is liquidatable
/// @param health Health of an agent's position
/// @param emergencyHealth Emergency health below which the grace period is voided
/// @param start Last liquidation start time
/// @param grace Grace period duration
/// @param expiry Liquidation duration after which it expires
function validateLiquidation(uint256 health, uint256 emergencyHealth, uint256 start, uint256 grace, uint256 expiry)
external
view
{
if (health >= 1e27) revert HealthFactorNotBelowThreshold();
if (emergencyHealth >= 1e27) {
@audit>> if (block.timestamp <= start + grace) revert GracePeriodNotOver();
if (block.timestamp >= start + expiry) revert LiquidationExpired();
}
}When we repay we burn debt tokens this will reduce the debt of the agent, but this reduced debt is used to get the new health before delegation is reduced.
Because the health factor will return a value above >= 1e27 which is incorrect because delegation has not been reduced by the liquidated value. hence the check is using a higher than the actual health factor.
This means the liquidator will be force to Open another liquidation with a new Grace Period.
The attacker is incentivized to carry out this attack has they will receive the liquidated value plus fee, making this attack profitable.
Internal Pre-conditions
- Account health drops below 1e27
- Liquidation is open giving the liquidator a grace period to respond.
External Pre-conditions
Attack Path
- Account health drops below 1e27
- Liquidation is open giving the liquidator a grace period to respond.
- Attacker liquidates immediately grace period passes with a partial liquidation
- Account is still liquidatable because the health factor wasn't returned back to health
- Attacker successfully updates the mapping with the health still below 1e27.
Impact
Health factor below 1e27 will have the Open liquidation call close prematurely, preventing other liquidators from liquidating early.
PoC
No response
Mitigation
Close the liquidation after the delegation Amount has been slashed to ensure that the health factor is indeed the Positions current health factor.
ValidationLogic.validateLiquidation(
health,
totalDelegation * $.emergencyLiquidationThreshold / totalDebt,
$.liquidationStart[params.agent],
$.grace,
$.expiry
);
(uint256 assetPrice,) = IOracle($.oracle).getPrice(params.asset);
uint256 bonus = ViewLogic.bonus($, params.agent);
uint256 maxLiquidation = ViewLogic.maxLiquidatable($, params.agent, params.asset);
uint256 liquidated = params.amount > maxLiquidation ? maxLiquidation : params.amount;
liquidated = BorrowLogic.repay(
$,
ILender.RepayParams({ agent: params.agent, asset: params.asset, amount: liquidated, caller: params.caller })
);
-- (,,,,, health) = ViewLogic.agent($, params.agent);
-- if (health >= 1e27) _closeLiquidation($, params.agent); // premature close ............. health is not health.....
liquidatedValue =
(liquidated + (liquidated * bonus / 1e27)) * assetPrice / (10 ** $.reservesData[params.asset].decimals);
if (totalSlashableCollateral < liquidatedValue) liquidatedValue = totalSlashableCollateral;
if (liquidatedValue > 0) IDelegation($.delegation).slash(params.agent, params.caller, liquidatedValue);
++ (,,,,, health) = ViewLogic.agent($, params.agent);
++ if (health >= 1e27) _closeLiquidation($, params.agent); // premature close ............. health is not health.....
emit Liquidate(params.agent, params.caller, params.asset, liquidated, liquidatedValue);
}