Borrower won't have time to repay funds to block forced liquidation in the case of ACTION.LIQUIDATE is enters and exits the pause state after some time such that in the meantime borrowers position go above the liquidation threshold. As a result borrower will be liquidated without being able to repay the debts.
Proof of Concept
Functions below will be executed in order to liquidate borrower in the case of the mentioned scenario happened while borrower is not be able to react.
venus/contracts/Comptroller.sol
function liquidateAccount(address borrower, LiquidationOrder[] calldata orders) external {
// We will accrue interest and update the oracle prices later during the liquidation
AccountLiquiditySnapshot memory snapshot = _getCurrentLiquiditySnapshot(borrower, _getLiquidationThreshold);
if (snapshot.totalCollateral > minLiquidatableCollateral) {
// You should use the regular vToken.liquidateBorrow(...) call
revert CollateralExceedsThreshold(minLiquidatableCollateral, snapshot.totalCollateral);
}
uint256 collateralToSeize = mul_ScalarTruncate(
Exp({ mantissa: liquidationIncentiveMantissa }),
snapshot.borrows
);
if (collateralToSeize >= snapshot.totalCollateral) {
// There is not enough collateral to seize. Use healBorrow to repay some part of the borrow
// and record bad debt.
revert InsufficientCollateral(collateralToSeize, snapshot.totalCollateral);
}
if (snapshot.shortfall == 0) {
revert InsufficientShortfall();
}
uint256 ordersCount = orders.length;
_ensureMaxLoops(ordersCount);
for (uint256 i; i < ordersCount; ++i) {
if (!markets[address(orders[i].vTokenBorrowed)].isListed) {
revert MarketNotListed(address(orders[i].vTokenBorrowed));
}
if (!markets[address(orders[i].vTokenCollateral)].isListed) {
revert MarketNotListed(address(orders[i].vTokenCollateral));
}
LiquidationOrder calldata order = orders[i];
order.vTokenBorrowed.forceLiquidateBorrow(
msg.sender,
borrower,
order.repayAmount,
order.vTokenCollateral,
true
);
}
VToken[] memory borrowMarkets = accountAssets[borrower];
uint256 marketsCount = borrowMarkets.length;
for (uint256 i; i < marketsCount; ++i) {
(, uint256 borrowBalance, ) = _safeGetAccountSnapshot(borrowMarkets[i], borrower);
require(borrowBalance == 0, "Nonzero borrow balance after liquidation");
}
}
venus/contracts/VToken.sol
function _liquidateBorrow(
address liquidator,
address borrower,
uint256 repayAmount,
VTokenInterface vTokenCollateral,
bool skipLiquidityCheck
) internal nonReentrant {
accrueInterest();
uint256 error = vTokenCollateral.accrueInterest();
if (error != NO_ERROR) {
// accrueInterest emits logs on errors, but we still want to log the fact that an attempted liquidation failed
revert LiquidateAccrueCollateralInterestFailed(error);
}
// _liquidateBorrowFresh emits borrow-specific logs on errors, so we don't need to
_liquidateBorrowFresh(liquidator, borrower, repayAmount, vTokenCollateral, skipLiquidityCheck);
}
venus/contracts/Comptroller.sol
function preLiquidateHook(
address vTokenBorrowed,
address vTokenCollateral,
address borrower,
uint256 repayAmount,
bool skipLiquidityCheck
) external override {
// Pause Action.LIQUIDATE on BORROWED TOKEN to prevent liquidating it.
// If we want to pause liquidating to vTokenCollateral, we should pause
// Action.SEIZE on it
_checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE);
oracle.updatePrice(vTokenBorrowed);
oracle.updatePrice(vTokenCollateral);
if (!markets[vTokenBorrowed].isListed) {
revert MarketNotListed(address(vTokenBorrowed));
}
if (!markets[vTokenCollateral].isListed) {
revert MarketNotListed(address(vTokenCollateral));
}
uint256 borrowBalance = VToken(vTokenBorrowed).borrowBalanceStored(borrower);
/* Allow accounts to be liquidated if the market is deprecated or it is a forced liquidation */
if (skipLiquidityCheck || isDeprecated(VToken(vTokenBorrowed))) {
if (repayAmount > borrowBalance) {
revert TooMuchRepay();
}
return;
}
/* The borrower must have shortfall and collateral > threshold in order to be liquidatable */
AccountLiquiditySnapshot memory snapshot = _getCurrentLiquiditySnapshot(borrower, _getLiquidationThreshold);
if (snapshot.totalCollateral <= minLiquidatableCollateral) {
/* The liquidator should use either liquidateAccount or healAccount */
revert MinimalCollateralViolated(minLiquidatableCollateral, snapshot.totalCollateral);
}
if (snapshot.shortfall == 0) {
revert InsufficientShortfall();
}
/* The liquidator may not repay more than what is allowed by the closeFactor */
uint256 maxClose = mul_ScalarTruncate(Exp({ mantissa: closeFactorMantissa }), borrowBalance);
if (repayAmount > maxClose) {
revert TooMuchRepay();
}
}
venus/contracts/VToken.sol
function _liquidateBorrowFresh(
address liquidator,
address borrower,
uint256 repayAmount,
VTokenInterface vTokenCollateral,
bool skipLiquidityCheck
) internal {
/* Fail if liquidate not allowed */
comptroller.preLiquidateHook(
address(this),
address(vTokenCollateral),
borrower,
repayAmount,
skipLiquidityCheck
);
/* Verify market's block number equals current block number */
if (accrualBlockNumber != _getBlockNumber()) {
revert LiquidateFreshnessCheck();
}
/* Verify vTokenCollateral market's block number equals current block number */
if (vTokenCollateral.accrualBlockNumber() != _getBlockNumber()) {
revert LiquidateCollateralFreshnessCheck();
}
/* Fail if borrower = liquidator */
if (borrower == liquidator) {
revert LiquidateLiquidatorIsBorrower();
}
/* Fail if repayAmount = 0 */
if (repayAmount == 0) {
revert LiquidateCloseAmountIsZero();
}
/* Fail if repayAmount = -1 */
if (repayAmount == type(uint256).max) {
revert LiquidateCloseAmountIsUintMax();
}
/* Fail if repayBorrow fails */
uint256 actualRepayAmount = _repayBorrowFresh(liquidator, borrower, repayAmount);
/////////////////////////
// EFFECTS & INTERACTIONS
// (No safe failures beyond this point)
/* We calculate the number of collateral tokens that will be seized */
(uint256 amountSeizeError, uint256 seizeTokens) = comptroller.liquidateCalculateSeizeTokens(
address(this),
address(vTokenCollateral),
actualRepayAmount
);
require(amountSeizeError == NO_ERROR, "LIQUIDATE_COMPTROLLER_CALCULATE_AMOUNT_SEIZE_FAILED");
/* Revert if borrower collateral token balance < seizeTokens */
require(vTokenCollateral.balanceOf(borrower) >= seizeTokens, "LIQUIDATE_SEIZE_TOO_MUCH");
// If this is also the collateral, call _seize internally to avoid re-entrancy, otherwise make an external call
if (address(vTokenCollateral) == address(this)) {
_seize(address(this), liquidator, borrower, seizeTokens);
} else {
vTokenCollateral.seize(liquidator, borrower, seizeTokens);
}
/* We emit a LiquidateBorrow event */
emit LiquidateBorrow(liquidator, borrower, actualRepayAmount, address(vTokenCollateral), seizeTokens);
}
Tools Used
Manual Review
Recommended Mitigation Steps
To ensure fair repayment opportunities for users after the repayment function has been resumed following a pause, consider implementing a timer that temporarily prevents liquidations for a specific duration. This approach allows users sufficient time to repay their positions without the risk of immediate liquidation.
By incorporating a timer mechanism, you can establish a grace period where liquidations are temporarily disabled after the repayment functionality is resumed. During this designated timeframe, users have a fair chance to manage their positions and fulfill their repayment obligations without the fear of immediate liquidation.
The specific duration of the timer, can be adjusted based on the requirements and dynamics of your system. It should provide users with an adequate window to reactivate their repayment activities and ensure a more equitable and user-friendly experience.
Lines of code
https://github.com/code-423n4/2023-05-venus/blob/8be784ed9752b80e6f1b8b781e2e6251748d0d7e/contracts/Comptroller.sol#L641
Vulnerability details
Impact
Borrower won't have time to repay funds to block forced liquidation in the case of ACTION.LIQUIDATE is enters and exits the pause state after some time such that in the meantime borrowers position go above the liquidation threshold. As a result borrower will be liquidated without being able to repay the debts.
Proof of Concept
Functions below will be executed in order to liquidate borrower in the case of the mentioned scenario happened while borrower is not be able to react.
Tools Used
Manual Review
Recommended Mitigation Steps
To ensure fair repayment opportunities for users after the repayment function has been resumed following a pause, consider implementing a timer that temporarily prevents liquidations for a specific duration. This approach allows users sufficient time to repay their positions without the risk of immediate liquidation.
By incorporating a timer mechanism, you can establish a grace period where liquidations are temporarily disabled after the repayment functionality is resumed. During this designated timeframe, users have a fair chance to manage their positions and fulfill their repayment obligations without the fear of immediate liquidation.
The specific duration of the timer, can be adjusted based on the requirements and dynamics of your system. It should provide users with an adequate window to reactivate their repayment activities and ensure a more equitable and user-friendly experience.
Assessed type
Other