Summary
The logic in LiquidationBranch::checkLiquidatableAccounts
is not working correctly and in some situation it will revert with index out of bound.
Vulnerability Details
Let's imagine that we have 20 as upperBound
and 11 as lowerBound
and we have for cachedAccountsIdsWithActivePositionsLength
13. The for cycle
will iterate from 11 to 20, and if 11 is valid and this trading account is liquidatable, it will throw index out of bound, because i will be 11 and the array have only 9 elements.
Poc
Add this test to CheckLiquidatableAccounts_Integration_Test
:
function testFuzz_index_out_of_bound(
uint256 marketId,
bool isLong
)
external
{
MarketConfig memory fuzzMarketConfig = getFuzzMarketConfig(marketId);
uint256 amountOfTradingAccounts = 20;
amountOfTradingAccounts = bound({ x: amountOfTradingAccounts, min: 1, max: 10 });
uint256 marginValueUsd = 10_000e18 / amountOfTradingAccounts;
uint256 initialMarginRate = fuzzMarketConfig.imr;
deal({ token: address(usdz), to: users.naruto.account, give: marginValueUsd });
for (uint256 i; i < amountOfTradingAccounts; i++) {
uint256 accountMarginValueUsd = marginValueUsd / amountOfTradingAccounts;
uint128 tradingAccountId = createAccountAndDeposit(accountMarginValueUsd, address(usdz));
openPosition(fuzzMarketConfig, tradingAccountId, initialMarginRate, accountMarginValueUsd, isLong);
}
setAccountsAsLiquidatable(fuzzMarketConfig, isLong);
uint256 lowerBound = 6;
uint256 upperBound = amountOfTradingAccounts;
uint128[] memory liquidatableAccountIds = perpsEngine.checkLiquidatableAccounts(lowerBound, upperBound);
assertEq(liquidatableAccountIds.length, upperBound - lowerBound);
for (uint256 i; i < liquidatableAccountIds.length; i++) {
assertEq(liquidatableAccountIds[i], i + 1);
}
}
Impact
Users and keepers of the protocol will be not able to check does subset of the accounts are liquidatable. It will be still possible to query the info, but this means that the keeper need to iterate all trading accounts and this will consume a lot of gas.
Tools Used
Manual review
Recommendations
The following change can be made:
for (uint256 i = 0; i < upperBound-lowerBound; i++) {
if (i + lowerBound >= cachedAccountsIdsWithActivePositionsLength) break;
uint128 tradingAccountId = uint128(globalConfiguration.accountsIdsWithActivePositions.at(i));
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(tradingAccountId);
(, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
SD59x18 marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
liquidatableAccountsIds[i] = tradingAccountId;
}
}
You should also adjust the logic of the checkUpkeep
in the LiquidationKeeper.sol