This report identifies a critical vulnerability in the liquidation process of Zaros, which leads to Denial of Service (DoS) and disruption of the market interests and metrics, affecting traders' positions and causing potential financial losses.
Note that the comment above this function indicates the devs intend to avoid DoS during liquidation by skipping open interest and skew caps. However, the current implementation incorrectly sets skew
and OI
to zero instead.
This means in order to reproduce this vulnerability, we only need to liquidate one account and the protocol will have its OI
and skew
set to zero.
This disrupts the protocol interests, positions, and fees as the following components rely on skew and OI:
Additionally, this will also cause a DoS in the system for positions that need to be updated, halting the market activity for those positions. I.e: Bob has an open position for the BTC market and he wants to increase his margin value, system will revert due to the following:
There are two tests on the PoC below.
One reproduces the DoS and the other shows the incorrect values set to the variables mentioned above.
function testGiveOneAccountIsLiquidated_systemWillSetIncorrectValueForSkewAndInterest_causingDoS_whenUserTryToUpdateHisPosition()
external
givenTheSenderIsARegisteredLiquidator
whenTheAccountsIdsArrayIsNotEmpty
givenAllAccountsExist
{
TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx = _setupMarketConditions(10);
_createOrdersAndLiquidateAccounts_settingTheSkewAndOI_toZero(ctx, true, 10);
_assertSkewAndOIasZero(ctx.fuzzMarketConfig.marketId);
changePrank({ msgSender: users.naruto.account });
uint128 accountIdToBeUpdated = _createTwoShortPositions_OneLongPosition_AndLiquidateOneShortPosition(ctx);
_assertSkewAndOIasZero(ctx.fuzzMarketConfig.marketId);
changePrank({ msgSender: users.naruto.account });
_updateExistingPosition(ctx.fuzzMarketConfig, accountIdToBeUpdated, ctx.initialMarginRate, ctx.accountMarginValueUsd, true);
}
function testGiveOneAccountIsLiquidated_theProtocolIsDisrupted_dueToWrongOIandSkew()
external
givenTheSenderIsARegisteredLiquidator
givenAllAccountsExist
{
uint256 marginValueUsdc = 100_000e6;
int128 userPositionSizeDelta = 10e18;
deal({ token: address(usdc), to: users.naruto.account, give: marginValueUsdc * 2 });
changePrank({ msgSender: users.naruto.account });
uint128 tradingAccountId = createAccountAndDeposit(marginValueUsdc, address(usdc));
_printSkewAndInterest(BTC_USD_MARKET_ID);
openManualPosition(
BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId, userPositionSizeDelta
);
skip(1 hours);
console.log("skew and OI after the first long position being opened");
_printSkewAndInterest(BTC_USD_MARKET_ID);
SD59x18 sizeDelta = sd59x18(userPositionSizeDelta);
uint256 correctFundingTime = block.timestamp;
UD60x18 correctMarkPriceX18 = perpsEngine.getMarkPrice(
BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE, sizeDelta.intoInt256()
);
uint256 correctOrderFeeInUsd = perpsEngine.exposed_getOrderFeeUsd(BTC_USD_MARKET_ID, sizeDelta, correctMarkPriceX18).intoUint128();
SD59x18 correctFundingRateX18 = perpsEngine.getFundingRate(BTC_USD_MARKET_ID);
SD59x18 correctNextFundingFeePerUnit = perpsEngine.exposed_getNextFundingFeePerUnit(
BTC_USD_MARKET_ID, correctFundingRateX18, correctMarkPriceX18
);
console.log("one open position mark price: %e", correctMarkPriceX18.intoUint256());
console.log("one open position order fee in usd: %e", correctOrderFeeInUsd);
console.log("one open position funding rate x18: %e", correctFundingRateX18.intoInt256());
console.log("one open position next funding fee per unit: %e", correctNextFundingFeePerUnit.intoInt256());
console.log("");
uint128 tradingAccountId2 = createAccountAndDeposit(marginValueUsdc, address(usdc));
openManualPosition(
BTC_USD_MARKET_ID, BTC_USD_STREAM_ID, MOCK_BTC_USD_PRICE, tradingAccountId2, userPositionSizeDelta
);
skip(1 hours);
console.log("skew and OI after the second long position being opened");
_printSkewAndInterest(BTC_USD_MARKET_ID);
correctMarkPriceX18 = perpsEngine.getMarkPrice(
BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE, sizeDelta.intoInt256()
);
correctOrderFeeInUsd = perpsEngine.exposed_getOrderFeeUsd(BTC_USD_MARKET_ID, sizeDelta, correctMarkPriceX18).intoUint128();
correctFundingRateX18 = perpsEngine.getFundingRate(BTC_USD_MARKET_ID);
correctNextFundingFeePerUnit = perpsEngine.exposed_getNextFundingFeePerUnit(
BTC_USD_MARKET_ID, correctFundingRateX18, correctMarkPriceX18
);
console.log("two open positions mark price: %e", correctMarkPriceX18.intoUint256());
console.log("two open positions order fee in usd: %e", correctOrderFeeInUsd);
console.log("two open positions funding rate x18: %e", correctFundingRateX18.intoInt256());
console.log("two open positions next funding fee per unit: %e", correctNextFundingFeePerUnit.intoInt256());
console.log("");
updateMockPriceFeed(BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE/2);
_liquidateOneAccount(tradingAccountId2);
updateMockPriceFeed(BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE);
skip(100 hours);
console.log("skew and OI after one of the long positions being liquidated");
_printSkewAndInterest(BTC_USD_MARKET_ID);
correctMarkPriceX18 = perpsEngine.getMarkPrice(
BTC_USD_MARKET_ID, MOCK_BTC_USD_PRICE, sizeDelta.intoInt256()
);
correctOrderFeeInUsd = perpsEngine.exposed_getOrderFeeUsd(BTC_USD_MARKET_ID, sizeDelta, correctMarkPriceX18).intoUint128();
correctFundingRateX18 = perpsEngine.getFundingRate(BTC_USD_MARKET_ID);
correctNextFundingFeePerUnit = perpsEngine.exposed_getNextFundingFeePerUnit(
BTC_USD_MARKET_ID, correctFundingRateX18, correctMarkPriceX18
);
console.log("one open position mark price: %e", correctMarkPriceX18.intoUint256());
console.log("one open position order fee in usd: %e", correctOrderFeeInUsd);
console.log("one open position funding rate x18: %e", correctFundingRateX18.intoInt256());
console.log("one open position next funding fee per unit: %e", correctNextFundingFeePerUnit.intoInt256());
}
function _assertSkewAndOIasZero(uint128 marketId) internal {
(UD60x18 longsOpenInterest, UD60x18 shortsOpenInterest, UD60x18 totalOpenInterest) = perpsEngine.getOpenInterest(marketId);
assertEq(0, perpsEngine.getSkew(marketId).intoInt256(), "skew != 0");
assertEq(0, longsOpenInterest.intoUint256(), "longs open interest != 0");
assertEq(0, shortsOpenInterest.intoUint256(), "shorts open interest != 0");
assertEq(0, totalOpenInterest.intoUint256(), "total open interest != 0");
}
function _createOrdersAndLiquidateAccounts_settingTheSkewAndOI_toZero(TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx, bool isLong, uint256 amountOfTradingAccounts) internal {
_createOpenPositions(ctx.fuzzMarketConfig, amountOfTradingAccounts, ctx.initialMarginRate, ctx.accountMarginValueUsd, isLong, ctx.accountsIds);
_printSkewAndInterest(ctx.fuzzMarketConfig.marketId);
setAccountsAsLiquidatable(ctx.fuzzMarketConfig, isLong);
ctx.nonLiquidatableTradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdz));
ctx.accountsIds[amountOfTradingAccounts] = ctx.nonLiquidatableTradingAccountId;
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
_liquidateOneAccount(ctx.accountsIds[2]);
skip(1 hours);
}
function _createTwoShortPositions_OneLongPosition_AndLiquidateOneShortPosition(TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx) internal returns(uint128 accountIdToBeUpdated) {
console.log("adding 2 short positions...");
uint128 accountIdToLiquidate = _createSingularPosition(ctx.fuzzMarketConfig, ctx.initialMarginRate, ctx.accountMarginValueUsd, false);
accountIdToBeUpdated = _createSingularPosition(ctx.fuzzMarketConfig, ctx.initialMarginRate, ctx.accountMarginValueUsd, false);
console.log("added... ");
console.log("");
console.log("adding 1 long position...");
_createSingularPosition(ctx.fuzzMarketConfig, ctx.initialMarginRate, ctx.accountMarginValueUsd, true);
console.log("added... ");
console.log("");
_printSkewAndInterest(ctx.fuzzMarketConfig.marketId);
setAccountsAsLiquidatable(ctx.fuzzMarketConfig, false);
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
console.log("liquidating one short position: %d ...", accountIdToLiquidate);
_liquidateOneAccount(accountIdToLiquidate);
console.log("liquidated...");
console.log("");
skip(1 hours);
}
function _createOpenPositions(MarketConfig memory marketConfig, uint256 numberOfAccounts, uint256 initialMarginRate, uint256 margin, bool isLong, uint128[] memory accountIds) internal {
for (uint128 i; i < numberOfAccounts; i++) {
accountIds[i] = _createSingularPosition(marketConfig, initialMarginRate, margin, isLong);
}
}
function _createSingularPosition(MarketConfig memory marketConfig, uint256 initialMarginRate, uint256 margin, bool isLong) internal returns (uint128 tradingAccountId) {
deal({ token: address(usdz), to: users.naruto.account, give: margin });
tradingAccountId = createAccountAndDeposit(margin, address(usdz));
openPosition(marketConfig, tradingAccountId, initialMarginRate, margin, isLong);
}
function _updateExistingPosition(MarketConfig memory marketConfig, uint128 tradingAccountId, uint256 initialMarginRate, uint256 margin, bool isLong) internal {
deal({ token: address(usdz), to: users.naruto.account, give: margin });
perpsEngine.depositMargin(tradingAccountId, address(usdz), margin);
openPosition(marketConfig, tradingAccountId, initialMarginRate, margin, isLong);
}
function _setupMarketConditions(uint256 amountOfTradingAccounts) internal returns(TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx) {
uint256 marketId = 1;
ctx.fuzzMarketConfig = getFuzzMarketConfig(marketId);
ctx.marginValueUsd = 10_000e18 / amountOfTradingAccounts;
ctx.initialMarginRate = ctx.fuzzMarketConfig.imr;
ctx.accountsIds = new uint128[](amountOfTradingAccounts + 2);
ctx.accountMarginValueUsd = ctx.marginValueUsd / (amountOfTradingAccounts + 1);
}
function _liquidateOneAccount(uint128 tradingAccountId) internal {
changePrank({ msgSender: liquidationKeeper });
skip(1 hours);
uint128[] memory liquidableAccountIds = new uint128[](1);
liquidableAccountIds[0] = tradingAccountId;
perpsEngine.liquidateAccounts(liquidableAccountIds);
}
function _printSkewAndInterest(uint128 marketId) internal {
console.log("--------------------");
console.log("skew: %e", perpsEngine.getSkew(marketId).intoInt256());
(UD60x18 longsOpenInterest, UD60x18 shortsOpenInterest, UD60x18 totalOpenInterest) = perpsEngine.getOpenInterest(marketId);
console.log("longs open interest: %e", longsOpenInterest.intoUint256());
console.log("shorts open interest: %e", shortsOpenInterest.intoUint256());
console.log("total open interest: %e", totalOpenInterest.intoUint256());
console.log("--------------------");
console.log("");
}
✅ Result: No DoS and the skew/OI values are adjusted correctly.