Summary
LiquidationBranch::liquidateAccounts()
executes PerpMarket::updateOpenInterest()
with incorrect parameters causing PerpMarket::Data::skew
and PerpMarket::Data::openInterest
to be reset to 0
Vulnerability Details
As the @>
annotation indicates, LiquidationContext memory ctx;
is used to save working data, but there is nothing in the code for the update of ctx.newOpenInterestX18
and ctx.newSkewX18
, and finally we call perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
with the default value 0
.
function liquidateAccounts(uint128[] calldata accountsIds) external {
if (accountsIds.length == 0) return;
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
if (!globalConfiguration.isLiquidatorEnabled[msg.sender]) {
revert Errors.LiquidatorNotRegistered(msg.sender);
}
@> LiquidationContext memory ctx;
ctx.liquidationFeeUsdX18 = ud60x18(globalConfiguration.liquidationFeeUsdX18);
for (uint256 i; i < accountsIds.length; i++) {
ctx.tradingAccountId = accountsIds[i];
if (ctx.tradingAccountId == 0) continue;
TradingAccount.Data storage tradingAccount = TradingAccount.loadExisting(ctx.tradingAccountId);
(, UD60x18 requiredMaintenanceMarginUsdX18, SD59x18 accountTotalUnrealizedPnlUsdX18) =
tradingAccount.getAccountMarginRequirementUsdAndUnrealizedPnlUsd(0, SD59x18_ZERO);
ctx.marginBalanceUsdX18 = tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18);
if (!TradingAccount.isLiquidatable(requiredMaintenanceMarginUsdX18, ctx.marginBalanceUsdX18)) {
continue;
}
ctx.liquidatedCollateralUsdX18 = tradingAccount.deductAccountMargin({
feeRecipients: FeeRecipients.Data({
marginCollateralRecipient: globalConfiguration.marginCollateralRecipient,
orderFeeRecipient: address(0),
settlementFeeRecipient: globalConfiguration.liquidationFeeRecipient
}),
pnlUsdX18: requiredMaintenanceMarginUsdX18,
orderFeeUsdX18: UD60x18_ZERO,
settlementFeeUsdX18: ctx.liquidationFeeUsdX18
});
MarketOrder.load(ctx.tradingAccountId).clear();
ctx.activeMarketsIds = tradingAccount.activeMarketsIds.values();
for (uint256 j; j < ctx.activeMarketsIds.length; j++) {
ctx.marketId = ctx.activeMarketsIds[j].toUint128();
PerpMarket.Data storage perpMarket = PerpMarket.load(ctx.marketId);
Position.Data storage position = Position.load(ctx.tradingAccountId, ctx.marketId);
ctx.oldPositionSizeX18 = sd59x18(position.size);
ctx.liquidationSizeX18 = -ctx.oldPositionSizeX18;
ctx.markPriceX18 = perpMarket.getMarkPrice(ctx.liquidationSizeX18, perpMarket.getIndexPrice());
ctx.fundingRateX18 = perpMarket.getCurrentFundingRate();
ctx.fundingFeePerUnitX18 = perpMarket.getNextFundingFeePerUnit(ctx.fundingRateX18, ctx.markPriceX18);
perpMarket.updateFunding(ctx.fundingRateX18, ctx.fundingFeePerUnitX18);
position.clear();
tradingAccount.updateActiveMarkets(ctx.marketId, ctx.oldPositionSizeX18, SD59x18_ZERO);
@> perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
}
emit LogLiquidateAccount(
msg.sender,
ctx.tradingAccountId,
ctx.activeMarketsIds.length,
requiredMaintenanceMarginUsdX18.intoUint256(),
ctx.marginBalanceUsdX18.intoInt256(),
ctx.liquidatedCollateralUsdX18.intoUint256(),
ctx.liquidationFeeUsdX18.intoUint128()
);
}
}
Combined with the target PerpMarket::updateOpenInterest()
method, the 0
value will be used to directly overwrite the PerpMarket::Data::skew
and PerpMarket::Data::openInterest
in the storage.
function updateOpenInterest(Data storage self, UD60x18 newOpenInterest, SD59x18 newSkew) internal {
self.skew = newSkew.intoInt256().toInt128();
self.openInterest = newOpenInterest.intoUint128();
}
Poc
We add the following test code to LiquidationBranch::liquidateAccounts()
// update perp market's open interest and skew; we don't enforce ipen
// interest and skew caps during liquidations as:
// 1) open interest and skew are both decreased by liquidations
// 2) we don't want liquidation to be DoS'd in case somehow those cap
// checks would fail
+ console.log("ctx.newOpenInterestX18:",ctx.newOpenInterestX18.intoUint256());
+ console.log("ctx.newSkewX18:",ctx.newSkewX18.intoInt256());
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
Please add the test code to test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol
and execute
function testLiquidationBranch_liquidateAccounts_calls_updateOpenInterest() public {
TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx;
ctx.fuzzMarketConfig = getFuzzMarketConfig(1);
uint256 amountOfTradingAccounts = 10;
uint256 timeDelta = 1 days;
bool isLong = true;
ctx.marginValueUsd = 10_000e18 / amountOfTradingAccounts;
ctx.initialMarginRate = ctx.fuzzMarketConfig.imr;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
ctx.accountsIds = new uint128[](amountOfTradingAccounts + 2);
ctx.accountMarginValueUsd = ctx.marginValueUsd / (amountOfTradingAccounts + 1);
MockPriceFeed priceFeed = MockPriceFeed(marketsConfig[1].priceAdapter);
(, int256 openPrice, , , ) = priceFeed.latestRoundData();
console.log("The price at the time of opening the position is:",openPrice);
for (uint256 i; i < amountOfTradingAccounts; i++) {
ctx.tradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdz));
openPosition(
ctx.fuzzMarketConfig,
ctx.tradingAccountId,
ctx.initialMarginRate,
ctx.accountMarginValueUsd,
isLong
);
ctx.accountsIds[i] = ctx.tradingAccountId;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
}
priceFeed.updateMockPrice(uint256(openPrice * 9800 / 10000));
changePrank({ msgSender: liquidationKeeper });
skip(timeDelta);
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
uint128[] memory accountsIds = new uint128[](2);
accountsIds[0] = ctx.accountsIds[0];
accountsIds[1] = ctx.accountsIds[1];
perpsEngine.liquidateAccounts(accountsIds);
}
From the output we can see that when perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
is executed, the parameter is 0, which will directly reset the corresponding parameter in the storage.
Code Snippet
https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/branches/LiquidationBranch.sol#L105-L222
https://github.com/Cyfrin/2024-07-zaros/blob/d687fe96bb7ace8652778797052a38763fbcbb1b/src/perpetuals/leaves/PerpMarket.sol#L354-L357
Impact
LiquidationBranch::liquidateAccounts()
executes PerpMarket::updateOpenInterest()
with incorrect parameters causing PerpMarket::Data::skew
and PerpMarket::Data::openInterest
to be reset to 0
Tools Used
Manual Review
Recommendations
Refer to the following code snippet of SettlementBranch::_fillOrder()
:
ctx.newPositionSizeX18 = sd59x18(ctx.newPosition.size);
(ctx.newOpenInterestX18, ctx.newSkewX18) =
perpMarket.checkOpenInterestLimits(sizeDeltaX18, ctx.oldPositionSizeX18, ctx.newPositionSizeX18);
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
We may consider making the following changes:
// update account's active markets; this calls EnumerableSet::remove which
// is why we are iterating over a memory copy of the trader's active markets
tradingAccount.updateActiveMarkets(ctx.marketId, ctx.oldPositionSizeX18, SD59x18_ZERO);
// calculate size delta
+ SD59x18 sizeDeltaX18 = ctx.liquidationSizeX18; // This should be -ctx.oldPositionSizeX18
// enforce open interest and skew limits for target market and calculate
// new open interest and new skew
+ (ctx.newOpenInterestX18, ctx.newSkewX18) =
+ perpMarket.checkOpenInterestLimits(sizeDeltaX18, ctx.oldPositionSizeX18, SD59x18_ZERO);
// update perp market's open interest and skew; we don't enforce ipen
// interest and skew caps during liquidations as:
// 1) open interest and skew are both decreased by liquidations
// 2) we don't want liquidation to be DoS'd in case somehow those cap
// checks would fail
perpMarket.updateOpenInterest(ctx.newOpenInterestX18, ctx.newSkewX18);
test again,it's work.
Ran 1 test for test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol:LiquidateAccounts_Integration_Test
[PASS] testLiquidationBranch_liquidateAccounts_calls_updateOpenInterest() (gas: 8001930)
Logs:
ctx.newOpenInterestX18: 734727236428118203
ctx.newSkewX18: 734727236428118203
ctx.newOpenInterestX18: 653090873921708277
ctx.newSkewX18: 653090873921708277