DeFiFoundry
60,000 USDC
View results
Submission Details
Severity: high
Invalid

A non-healthy account can sidestep being liquidated

Summary

An account can sidestep being liquidated, even if they are non-healthy which leaves protocol with bad debts.

Vulnerability Details

Inorder to liquidate the liquidateAccounts() is queried by accepted liquidators.

So as not to liquidate users that are safe not liquidatable LTV wise, a check is being implemented, see https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/branches/LiquidationBranch.sol#L127-L148

function liquidateAccounts(uint128[] calldata accountsIds) external {
..snip
for (uint256 i; i < accountsIds.length; i++) {
// store current accountId being liquidated in working data
ctx.tradingAccountId = accountsIds[i];
// sanity check for non-sensical accountId; should never be true
if (ctx.tradingAccountId == 0) continue;
// load account's leaf (data + functions)
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(ctx.tradingAccountId);
// get account's required maintenance margin & unrealized PNL
(, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
// get then save margin balance into working data
ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
//@audit
// if account is not liquidatable, skip to next account
// account is liquidatable if requiredMaintenanceMarginUsdX18 > ctx.marginBalanceUsdX18
if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18)) {
continue;
..snip
}

Now see https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/TradingAccount.sol#L342-L352

function isLiquidatable(
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 marginBalanceUsdX18
)
internal
pure
returns (bool)
{
return requiredMaintenanceMarginUsdX18.intoSD59x18().gt(marginBalanceUsdX18);
}

Thsi is the function that checks if the account is liquidatable and to do this the requiredMaintenanceMarginUsdX18 & marginBalanceUsdX18 need to be provided and queried from both tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd() & tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18).

Issue however is that both these functions need to undergo a heavy loop that eats up gas, which is asides other aspects of the liquidation process.

Here is getAccountMarginRequirementUsdAndUnrealizedPnlUsd() https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/TradingAccount.sol#L210-L304

function getAccountMarginRequirementUsdAndUnrealizedPnlUsd(
Data storage self,
uint128 targetMarketId,
SD59x18 sizeDeltaX18
)
internal
view
returns (
UD60x18 requiredInitialMarginUsdX18,
UD60x18 requiredMaintenanceMarginUsdX18,
SD59x18 accountTotalUnrealizedPnlUsdX18
)
{
..snip
// iterate over account's active markets
uint256 cachedActiveMarketsIdsLength = self.activeMarketsIds.length();
for (uint256 i; i < cachedActiveMarketsIdsLength; i++) {
uint128 marketId = self.activeMarketsIds.at(i).toUint128();
// if the previous special processing occured, skip this iteration
if (marketId == targetMarketId) {
continue;
}
// load the current active market's perp information
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
// load the account's position in that market
Position.Data storage position = Position.load(self.id, marketId);
// calculate price impact as if trader were to close the entire position
UD60x18 markPrice = perpMarket.getMarkPrice(sd59x18(-position.size), perpMarket.getIndexPrice());
// get funding fee per unit
SD59x18 fundingFeePerUnit =
perpMarket.getNextFundingFeePerUnit(perpMarket.getCurrentFundingRate(), markPrice);
// calculate notional value
UD60x18 notionalValueX18 = position.getNotionalValue(markPrice);
// calculate margin requirements
(UD60x18 positionInitialMarginUsdX18, UD60x18 positionMaintenanceMarginUsdX18) = Position
.getMarginRequirement(
notionalValueX18,
ud60x18(perpMarket.configuration.initialMarginRateX18),
ud60x18(perpMarket.configuration.maintenanceMarginRateX18)
);
// get unrealized pnl + accrued funding fees
SD59x18 positionUnrealizedPnl =
position.getUnrealizedPnl(markPrice).add(position.getAccruedFunding(fundingFeePerUnit));
// update cumulative outputs
requiredInitialMarginUsdX18 = requiredInitialMarginUsdX18.add(positionInitialMarginUsdX18);
requiredMaintenanceMarginUsdX18 = requiredMaintenanceMarginUsdX18.add(positionMaintenanceMarginUsdX18);
accountTotalUnrealizedPnlUsdX18 = accountTotalUnrealizedPnlUsdX18.add(positionUnrealizedPnl);
}
.snip
}

In the same execution a call is still made to getMarginBalanceUsd()https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/TradingAccount.sol#L169-L201

function getMarginBalanceUsd(
Data storage self,
SD59x18 activePositionsUnrealizedPnlUsdX18
)
internal
view
returns (SD59x18 marginBalanceUsdX18)
{
// cache colllateral length
uint256 cachedMarginCollateralBalanceLength = self.marginCollateralBalanceX18.length();
// iterate over every collateral account has deposited
for (uint256 i; i < cachedMarginCollateralBalanceLength; i++) {
// read key/value from storage for current iteration
(address collateralType, uint256 balance) = self.marginCollateralBalanceX18.at(i);
// load collateral margin config for this collateral type
MarginCollateralConfiguration.Data storage marginCollateralConfiguration =
MarginCollateralConfiguration.load(collateralType);
// calculate the collateral's "effective" balance as:
// collateral_price * deposited_balance * collateral_loan_to_value_ratio
UD60x18 adjustedBalanceUsdX18 = marginCollateralConfiguration.getPrice().mul(ud60x18(balance)).mul(
ud60x18(marginCollateralConfiguration.loanToValue)
);
// add this account's "effective" collateral balance to cumulative output
marginBalanceUsdX18 = marginBalanceUsdX18.add(adjustedBalanceUsdX18.intoSD59x18());
}
// finally add the unrealized PNL to the cumulative output
marginBalanceUsdX18 = marginBalanceUsdX18.add(activePositionsUnrealizedPnlUsdX18);
}

This then allows a malicious user to open countless positions in different markets and also deposit all collateral possible to ensure the attempt at liquidating them reverts with an OOG error.

Impact

As hinted under Vulnerability Details, a malicious tech savvy user can have their accounts sidestep liquidations by opening countless positions in different markets and also deposit all collateral possible to ensure the attempt at liquidating them reverts with an OOG error, due to how heavy the gas cost of the execution would be, essentially leaving the protocol with bad debts and making risk free decisions in-protocol or elsewhere.

Tools Used

Manual review

Recommendations

Consider having a max amount of markets an account can integrate with.

Updates

Lead Judging Commences

inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Too generic

Appeal created

bauchibred Submitter
12 months ago
inallhonesty Lead Judge
12 months ago
inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Invalidated
Reason: Too generic

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.