Summary
The checkLiquidatableAccountsfunction which is called from the LiquidationKeeper contracts is not calculated correctly.
Vulnerability Details
The checkLiquidatableAccountshas two parameters uint256 lowerBound, uint256 upperBound and it should be able to take the min of 0 and the max of type(uint256).max but the function only works correctly when the the lowerBound is zero, this is due to the the way liquidatableAccountsIds is being derived, in order to get the liquidatableAccountsIds array, the function sets the size of the array by subtracting the upperBound from the lowerBound.
Test A - If the lowerbound is 0 and the upperBound is 20, the array will have a size of 20
Test B - If the lowerbound is 1 and the upperBound is 21, the array will have a size of 20
Only test A works in the checkLiquidatableAccountsfunctions even though they both have an array size of 20, the problem can be traced to the for loop which starts at 1 or any other number apart from zero.
liquidatableAccountsIds[i] = tradingAccountId;
The statement above which can be found on line 42 in the code below assumes that the lowerbound will start at zero and when the lowerbound does not start at zero, there will not be enough slots in the array for the other accounts which will lead to a panic: array out-of-bounds access (0x32) error.
function checkLiquidatableAccounts(
uint256 lowerBound,
uint256 upperBound
)
external
view
returns (uint128[] memory liquidatableAccountsIds)
{
@> liquidatableAccountsIds = new uint128[](upperBound - lowerBound);
if (liquidatableAccountsIds.length == 0) return liquidatableAccountsIds;
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
uint256 cachedAccountsIdsWithActivePositionsLength =
globalConfiguration.accountsIdsWithActivePositions.length();
@> for (uint256 i = lowerBound; i < upperBound; i++) {
if (i >= 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;
}
}
}
POC
Add test to the test/integration/perpetuals/liquidation-branch/checkLiquidatableAccounts/checkLiquidatableAccounts.t.sol and run it.
function test_errorWhenCheckingLiquidatableAccounts(
uint256 marketId,
bool isLong,
uint256 amountOfTradingAccounts
)
external
{
MarketConfig memory fuzzMarketConfig = getFuzzMarketConfig(marketId);
amountOfTradingAccounts = 30;
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 = 1;
uint256 upperBound = 21;
uint128[] memory liquidatableAccountIds = perpsEngine.checkLiquidatableAccounts(lowerBound, upperBound);
}
Impact
When the liquidation keepers call the checkLiquidatableAccounts with lowerBound parameter that is not zero, it will always revert.
Accounts that should be liquidated will not be liquidated on time due to the errors encountered when the keepers call the checkLiquidatableAccounts function.
Tools Used
Manual Review
Recommendations
Rewrite the checkLiquidatableAccounts logic to accomdate lowerBound that are greater than zero.