Summary
The checkLiquidatableAccounts function is designed to assist the LiquidationKeeper and other actors in identifying liquidatable accounts by processing them in manageable batches. Due to the extensive volume of trading over time, numerous trading accounts may become undercollateralized. A single call to check all liquidatable accounts would fail due to gas limitations, making batch processing essential. However, an out-of-bounds error occurs due to incorrect array indexing. This error prevents the protocol from correctly identifying and processing liquidatable accounts.
Vulnerability Details
The function checkLiquidatableAccounts creates an array liquidatableAccountsIds with a size of upperBound - lowerBound. The loop iterates from lowerBound to upperBound, using i as the index. For example, if lowerBound is 5 and upperBound is 10, the array size is 5. When i exceeds this size, it causes an out-of-bounds error, preventing the protocol from correctly identifying and processing liquidatable accounts.
https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/branches/LiquidationBranch.sol#L42-L86
function checkLiquidatableAccounts(
uint256 lowerBound,
uint256 upperBound
)
external
view
returns (uint128[] memory liquidatableAccountsIds)
{
@> liquidatableAccountsIds = new uint128[](upperBound - lowerBound);
...
@> for (uint256 i = lowerBound; i < upperBound; i++) {
...
if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
@> liquidatableAccountsIds[i] = tradingAccountId;
}
}
}
PoC
-
Add the following test on checkLiquidatableAccounts.t.sol and run:
forge test --match-test testGivenLowerBoundGreaterThanZero_transactionWillRevert
function testGivenLowerBoundGreaterThanZero_transactionWillRevert() public {
uint256 USER_STARTING_BALANCE = 100_000e6;
int128 USER_POS_SIZE_DELTA = 10e18;
int128 USER_POS_NOT_LIQUIDATABLE_SIZE_DELTA = 1e18;
deal({ token: address(usdc), to: users.naruto.account, give: USER_STARTING_BALANCE * 10 });
changePrank({ msgSender: users.naruto.account });
for (uint256 i = 0; i < 5; i++) {
uint128 tradingAccountId = createAccountAndDeposit(USER_STARTING_BALANCE, address(usdc));
openManualPosition(BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId, USER_POS_NOT_LIQUIDATABLE_SIZE_DELTA);
skip(1 minutes);
}
for (uint256 i = 0; i < 5; i++) {
uint128 tradingAccountId = createAccountAndDeposit(USER_STARTING_BALANCE, address(usdc));
openManualPosition(BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId, USER_POS_SIZE_DELTA);
skip(1 minutes);
}
updateMockPriceFeed(BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE/2);
uint128[] memory notLiquidatableAccountIds = perpsEngine.checkLiquidatableAccounts(0, 5);
for (uint256 i = 0; i < notLiquidatableAccountIds.length; i++) {
assertEq(notLiquidatableAccountIds[i], 0);
}
vm.expectRevert(stdError.indexOOBError);
uint128[] memory liquidatableAccountIds = perpsEngine.checkLiquidatableAccounts(5, 10);
}
Output: after trying to check liquidatable accounts from (5,10) the function reverts with out-of-bounds.
[PASS] testGivenLowerBoundGreaterThanZero_transactionWillRevert() (gas: 7561246)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.43ms (7.94ms CPU time)
Impact
DoS on Liquidation: The protocol can't identify and liquidate under-collateralized accounts properly.
Bad debt: Increases the risk of bad debt in the system due to unliquidated positions.
Liquidation Keeper fees: Causes failed transactions, wasting fees to run the Keeper.
Tools Used
Manual Review & Foundry
Recommendations
By adjusting the index calculation to i - lowerBound, we ensure that values are correctly assigned within the bounds of the array, preventing out-of-bounds errors and allowing the liquidation process to function correctly
// checkLiquidatableAccounts function
if (TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, marginBalanceUsdX18)) {
- liquidatableAccountsIds[i] = tradingAccountId
+ liquidatableAccountsIds[i - lowerBound] = tradingAccountId;
}