Summary
LiquidationBranch::liquidateAccounts()
executed TradingAccount::deductAccountMargin()
with incorrect parameters, causing the project party to lose a large amount of funds.
Vulnerability Details
During the execution of LiquidationBranch::liquidateAccounts()
, requiredMaintenanceMarginUsdX18
was used as pnlUsdX18
to call TradingAccount::deductAccountMargin()
. Since the value of requiredMaintenanceMarginUsdX18
is basically constant, it cannot reflect the actual loss of the user's account. No matter how much the user's actual loss is, in the end, only the funds corresponding to requiredMaintenanceMarginUsdX18
will be deducted, which will cause huge losses to the project party.
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()
);
}
}
Poc
Please add the test code to test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol
and execute
import { MockPriceFeed } from "test/mocks/MockPriceFeed.sol";
import {console2,console} from "forge-std/Test.sol";
import { unwrap } from "@prb-math/SD59x18.sol";
import { unwrap } from "@prb-math/UD60x18.sol";
function testLiquidationBranch_liquidateAccounts_error() public {
TestFuzz_GivenThereAreLiquidatableAccountsInTheArray_Context memory ctx;
ctx.fuzzMarketConfig = getFuzzMarketConfig(0);
uint256 amountOfTradingAccounts = 1;
uint256 timeDelta = 1 days;
bool isLong = true;
ctx.marginValueUsd = 10_000e18;
ctx.initialMarginRate = ctx.fuzzMarketConfig.imr;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
ctx.accountsIds = new uint128[](amountOfTradingAccounts);
ctx.accountMarginValueUsd = ctx.marginValueUsd;
MockPriceFeed priceFeed = MockPriceFeed(marketsConfig[1].priceAdapter);
(, int256 openPrice, , , ) = priceFeed.latestRoundData();
console.log("The price at the time of opening the position is:",openPrice);
ctx.tradingAccountId = createAccountAndDeposit(ctx.accountMarginValueUsd, address(usdz));
console.log("---------- Account status before transaction ----------");
(SD59x18 marginBalanceUsdX18, , ,SD59x18 availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(ctx.tradingAccountId);
console2.log("marginBalanceUsdX18:",unwrap(marginBalanceUsdX18));
console2.log("availableMarginUsdX18:",unwrap(availableMarginUsdX18));
openPosition(
ctx.fuzzMarketConfig,
ctx.tradingAccountId,
ctx.initialMarginRate,
ctx.marginValueUsd,
isLong
);
(marginBalanceUsdX18, , ,availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(ctx.tradingAccountId);
console2.log("marginBalanceUsdX18:",unwrap(marginBalanceUsdX18));
console2.log("availableMarginUsdX18:",unwrap(availableMarginUsdX18));
ctx.accountsIds[0] = ctx.tradingAccountId;
deal({ token: address(usdz), to: users.naruto.account, give: ctx.marginValueUsd });
console.log("---------- Initial Position Status ----------");
Position.State memory positionState = perpsEngine.getPositionState(ctx.tradingAccountId,ctx.fuzzMarketConfig.marketId,uint256(openPrice));
console2.log("positionState.notionalValueX18:",unwrap(positionState.notionalValueX18));
console2.log("positionState.initialMarginUsdX18:",unwrap(positionState.initialMarginUsdX18));
console2.log("positionState.maintenanceMarginUsdX18:",unwrap(positionState.maintenanceMarginUsdX18));
uint256 snapshot = vm.snapshot();
vm.revertTo(snapshot);
console.log("---------- The first price drop is 0.6% ----------");
priceFeed.updateMockPrice(uint256(openPrice * 9940 / 10000));
(, int256 answer, , , ) = priceFeed.latestRoundData();
console.log("The price of the first liquidation:",answer);
changePrank({ msgSender: liquidationKeeper });
skip(timeDelta);
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
console2.log("Account Total Unrealized Pnl:",unwrap(perpsEngine.getAccountTotalUnrealizedPnl(ctx.tradingAccountId)));
perpsEngine.liquidateAccounts(ctx.accountsIds);
positionState = perpsEngine.getPositionState(ctx.tradingAccountId,ctx.fuzzMarketConfig.marketId,uint256(openPrice));
assertEq(0,unwrap(positionState.notionalValueX18));
(marginBalanceUsdX18, , ,availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(ctx.tradingAccountId);
console2.log("marginBalanceUsdX18:",unwrap(marginBalanceUsdX18));
console2.log("availableMarginUsdX18:",unwrap(availableMarginUsdX18));
vm.revertTo(snapshot);
console.log("---------- The second price drop is 0.8% ----------");
priceFeed.updateMockPrice(uint256(openPrice * 9920 / 10000));
(, answer, , , ) = priceFeed.latestRoundData();
console.log("The price of the second liquidation:",answer);
changePrank({ msgSender: liquidationKeeper });
skip(timeDelta);
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
console2.log("Account Total Unrealized Pnl:",unwrap(perpsEngine.getAccountTotalUnrealizedPnl(ctx.tradingAccountId)));
perpsEngine.liquidateAccounts(ctx.accountsIds);
positionState = perpsEngine.getPositionState(ctx.tradingAccountId,ctx.fuzzMarketConfig.marketId,uint256(openPrice));
assertEq(0,unwrap(positionState.notionalValueX18));
(marginBalanceUsdX18, , ,availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(ctx.tradingAccountId);
console2.log("marginBalanceUsdX18:",unwrap(marginBalanceUsdX18));
console2.log("availableMarginUsdX18:",unwrap(availableMarginUsdX18));
vm.revertTo(snapshot);
console.log("---------- The third price drop is 1% ----------");
priceFeed.updateMockPrice(uint256(openPrice * 9900 / 10000));
(, answer, , , ) = priceFeed.latestRoundData();
console.log("The price of the third liquidation:",answer);
changePrank({ msgSender: liquidationKeeper });
skip(timeDelta);
ctx.expectedLastFundingRate = perpsEngine.getFundingRate(ctx.fuzzMarketConfig.marketId).intoInt256();
ctx.expectedLastFundingTime = block.timestamp;
console2.log("Account Total Unrealized Pnl:",unwrap(perpsEngine.getAccountTotalUnrealizedPnl(ctx.tradingAccountId)));
perpsEngine.liquidateAccounts(ctx.accountsIds);
positionState = perpsEngine.getPositionState(ctx.tradingAccountId,ctx.fuzzMarketConfig.marketId,uint256(openPrice));
assertEq(0,unwrap(positionState.notionalValueX18));
(marginBalanceUsdX18, , ,availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(ctx.tradingAccountId);
console2.log("marginBalanceUsdX18:",unwrap(marginBalanceUsdX18));
console2.log("availableMarginUsdX18:",unwrap(availableMarginUsdX18));
}
The output is as follows. We compare the status before and after liquidation in three cases of decline. It can be found that no matter which state, due to the incorrect use of requiredMaintenanceMarginUsdX18
, the actual amount liquidated is far less than the loss amount in the account, which will cause serious losses to the project's funds.
output:
Ran 1 test for test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol:LiquidateAccounts_Integration_Test
[PASS] testLiquidationBranch_liquidateAccounts_error() (gas: 2028826)
Logs:
The price at the time of opening the position is: 100000000000000000000000
---------- Account status before transaction ----------
marginBalanceUsdX18: 10000000000000000000000
availableMarginUsdX18: 10000000000000000000000
sd59x18(position.size) 0
changePrank is deprecated. Please use vm.startPrank instead.
changePrank is deprecated. Please use vm.startPrank instead.
marginBalanceUsdX18: 9262160061507351887025
availableMarginUsdX18: 64160830349250474826
---------- Initial Position Status ----------
positionState.notionalValueX18: 919799923115810141219901
positionState.initialMarginUsdX18: 9197999231158101412199
positionState.maintenanceMarginUsdX18: 4598999615579050706099
---------- The first price drop is 0.6% ----------
The price of the first liquidation: 99400000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -5518812153024666646630
@> marginBalanceUsdX18: 4685754443621775485163
@> availableMarginUsdX18: 4685754443621775485163
---------- The second price drop is 0.8% ----------
The price of the second liquidation: 99200000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -7358411973875341645769
@> marginBalanceUsdX18: 4694952442852933586575
@> availableMarginUsdX18: 4694952442852933586575
---------- The third price drop is 1% ----------
The price of the third liquidation: 99000000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -9198011794726016644908
@> marginBalanceUsdX18: 4704150442084091687987
@> availableMarginUsdX18: 4704150442084091687987
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/TradingAccount.sol#L481-L578
Impact
LiquidationBranch::liquidateAccounts()
executed TradingAccount::deductAccountMargin()
with incorrect parameters, causing the project party to lose a large amount of funds.
Tools Used
Manual Review
Recommendations
Combined with the following comment of the TradingAccount::deductAccountMargin()
function, we should consider using accountTotalUnrealizedPnlUsdX18
to replace requiredMaintenanceMarginUsdX18
as the calling parameter.
@>
function deductAccountMargin(
Data storage self,
FeeRecipients.Data memory feeRecipients,
UD60x18 pnlUsdX18,
UD60x18 settlementFeeUsdX18,
UD60x18 orderFeeUsdX18
)
internal
returns (UD60x18 marginDeductedUsdX18)
{
@> if (pnlUsdX18.gt(UD60x18_ZERO) && ctx.pnlDeductedUsdX18.lt(pnlUsdX18)) {
(ctx.withdrawnMarginUsdX18, ctx.isMissingMargin) = withdrawMarginUsd(
self,
collateralType,
ctx.marginCollateralPriceUsdX18,
pnlUsdX18.sub(ctx.pnlDeductedUsdX18),
feeRecipients.marginCollateralRecipient
);
ctx.pnlDeductedUsdX18 = ctx.pnlDeductedUsdX18.add(ctx.withdrawnMarginUsdX18);
}
marginDeductedUsdX18 =
ctx.settlementFeeDeductedUsdX18.add(ctx.orderFeeDeductedUsdX18).add(ctx.pnlDeductedUsdX18);
}
For example:
Check the unrealized profit and loss value
Reverse the sign
SD59x18 -> UD60x18
// The unrealized profit and loss of an account that can be liquidated should be expected to be less than 0. We still perform checks to ensure safety.
+ require(accountTotalUnrealizedPnlUsdX18.lt(SD59x18_ZERO),"accountTotalUnrealizedPnlUsdX18 error");
// Flip Symbol
+ accountTotalUnrealizedPnlUsdX18 = unary(accountTotalUnrealizedPnlUsdX18);
// deduct maintenance margin from the account's collateral
// settlementFee = liquidationFee
ctx.liquidatedCollateralUsdX18 = tradingAccount.deductAccountMargin({
feeRecipients: FeeRecipients.Data({
marginCollateralRecipient: globalConfiguration.marginCollateralRecipient,
orderFeeRecipient: address(0),
settlementFeeRecipient: globalConfiguration.liquidationFeeRecipient
}),
- pnlUsdX18: requiredMaintenanceMarginUsdX18,
+ pnlUsdX18: ud60x18(uint256(accountTotalUnrealizedPnlUsdX18.intoInt256())), // SD59x18 -> UD60x18
orderFeeUsdX18: UD60x18_ZERO,
settlementFeeUsdX18: ctx.liquidationFeeUsdX18
});
test again,it's work
Ran 1 test for test/integration/perpetuals/liquidation-branch/liquidateAccounts/liquidateAccounts.t.sol:LiquidateAccounts_Integration_Test
[PASS] testLiquidationBranch_liquidateAccounts_error() (gas: 2050012)
The price at the time of opening the position is: 100000000000000000000000
---------- Account status before transaction ----------
marginBalanceUsdX18: 10000000000000000000000
availableMarginUsdX18: 10000000000000000000000
sd59x18(position.size) 0
changePrank is deprecated. Please use vm.startPrank instead.
changePrank is deprecated. Please use vm.startPrank instead.
marginBalanceUsdX18: 9262160061507351887025
availableMarginUsdX18: 64160830349250474826
---------- Initial Position Status ----------
positionState.notionalValueX18: 919799923115810141219901
positionState.initialMarginUsdX18: 9197999231158101412199
positionState.maintenanceMarginUsdX18: 4598999615579050706099
---------- The first price drop is 0.6% ----------
The price of the first liquidation: 99400000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -5518812153024666646630
requiredMaintenanceMarginUsdX18 4571405617885576401862
ctx.marginBalanceUsdX18 3743347908482685240395
@> marginBalanceUsdX18: 3738347908482685240395
availableMarginUsdX18: 3738347908482685240395
---------- The second price drop is 0.8% ----------
The price of the second liquidation: 99200000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -7358411973875341645769
requiredMaintenanceMarginUsdX18 4562207618654418300450
ctx.marginBalanceUsdX18 1903748087632010241256
@> marginBalanceUsdX18: 1898748087632010241256
availableMarginUsdX18: 1898748087632010241256
---------- The third price drop is 1% ----------
The price of the third liquidation: 99000000000000000000000
changePrank is deprecated. Please use vm.startPrank instead.
@> Account Total Unrealized Pnl: -9198011794726016644908
requiredMaintenanceMarginUsdX18 4553009619423260199038
ctx.marginBalanceUsdX18 64148266781335242117
@> marginBalanceUsdX18: 59148266781335242117
availableMarginUsdX18: 59148266781335242117