Summary
A malicious user can DOS all offchain orders, leaving the protocol insolvent. They exploit the lack of simulation for limit orders (limit/take profit/ stop limit) by creating zero-trade size orders. These are batched and executed once price criteria are met, causing repeated DOS attacks at low cost.
Vulnerability Details
A malicious user can exploit the current implementation of offchain orders by creating multiple offchain orders with a trade size of 0. This can lead to a Denial of Service (DoS) on the offchain order execution process, causing significant disruptions in the protocol's operation. The primary issues are:
1. **Zero-Size Orders**: Malicious users can create offchain orders with a trade size of 0, which can cause the entire batch of offchain orders to fail during execution.
2. **Validation are done**: Offchain orders are validated on chain at the point of execution before being included in the batch for onchain execution, making it possible for invalid orders to be processed (DOS for batched transactions).
Offchain order are batched together on chain before execution and executed once this gives room for exploit and DOSing the entire cached transactions.
@audit>> note >>>
function fillOffchainOrders(
uint128 marketId,
@audit>> batched transactions >>> OffchainOrder.Data[] calldata offchainOrders,
bytes calldata priceData
)
external
onlyOffchainOrdersKeeper(marketId)
{
FillOffchainOrders_Context memory ctx;
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID);
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
(ctx.bidX18, ctx.askX18) =
settlementConfiguration.verifyOffchainPrice(priceData, globalConfiguration.maxVerificationDelay);
@audit>> loop >>> for (uint256 i; i < offchainOrders.length; i++) {
ctx.offchainOrder = offchainOrders[i];
@audit>> DOS attack possible revertion in loop during validation>>> if (ctx.offchainOrder.sizeDelta == 0) {
revert Errors.ZeroInput("offchainOrder.sizeDelta");
}
TradingAccount.Data storage tradingAccount =
@audit>> DOS attack possible revertion in loop during validation >>> TradingAccount.loadExisting(ctx.offchainOrder.tradingAccountId);
@audit>> DOS attack possible revertion in loop during validation >>> if (marketId != ctx.offchainOrder.marketId) {
revert Errors.OrderMarketIdMismatch(marketId, ctx.offchainOrder.marketId);
}
@audit>> DOS attack possible revertion in loop during validation >>> if (ctx.offchainOrder.nonce != tradingAccount.nonce) {
revert Errors.InvalidSignedNonce(tradingAccount.nonce, ctx.offchainOrder.nonce);
}
ctx.structHash = keccak256(
abi.encode(
Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
ctx.offchainOrder.tradingAccountId,
ctx.offchainOrder.marketId,
ctx.offchainOrder.sizeDelta,
ctx.offchainOrder.targetPrice,
ctx.offchainOrder.shouldIncreaseNonce,
ctx.offchainOrder.nonce,
ctx.offchainOrder.salt
)
);
@audit>> DOS attack possible revertion in loop during validation >>> if (tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash]) {
revert Errors.OrderAlreadyFilled(ctx.offchainOrder.tradingAccountId, ctx.offchainOrder.salt);
}
ctx.signer = ECDSA.recover(
_hashTypedDataV4(ctx.structHash), ctx.offchainOrder.v, ctx.offchainOrder.r, ctx.offchainOrder.s
);
@audit>> DOS attack possible revertion in loop during validation >>> if (ctx.signer != tradingAccount.owner) {
revert Errors.InvalidOrderSigner(ctx.signer, tradingAccount.owner);
}
ctx.isBuyOrder = ctx.offchainOrder.sizeDelta > 0;
ctx.indexPriceX18 = ctx.isBuyOrder ? ctx.askX18 : ctx.bidX18;
ctx.fillPriceX18 = perpMarket.getMarkPrice(sd59x18(ctx.offchainOrder.sizeDelta), ctx.indexPriceX18);
ctx.isFillPriceValid = (ctx.isBuyOrder && ctx.offchainOrder.targetPrice <= ctx.fillPriceX18.intoUint256())
|| (!ctx.isBuyOrder && ctx.offchainOrder.targetPrice >= ctx.fillPriceX18.intoUint256());
if (!ctx.isFillPriceValid) {
continue;
}
if (ctx.offchainOrder.shouldIncreaseNonce) {
unchecked {
tradingAccount.nonce++;
}
}
tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash] = true;
_fillOrder(
ctx.offchainOrder.tradingAccountId,
marketId,
SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID,
sd59x18(ctx.offchainOrder.sizeDelta),
ctx.fillPriceX18
);
}
}
Market orders are simulated therefore there are little or no checks within fill market order because of the simulation function.
NOTE offchain orders are batched hence these DOS attack is possible.
For honest users -
Also if the DOS / revertion do occur. here below are the areas where a reversion can still be triggered WHEN FILLING THE ORDER.
function _fillOrder(
uint128 tradingAccountId,
uint128 marketId,
uint128 settlementConfigurationId,
SD59x18 sizeDeltaX18,
UD60x18 fillPriceX18
)
internal
virtual
{
--------------------------------------------------------------
@audit>>> No try and catch--offchain can revert here/reversion in loop during execution(DOS)>>> perpMarket.checkTradeSize(sizeDeltaX18);
------------------------------------------------------------------------
@audit>>>No try and catch--offchain can revert here/reversion in loop during execution(DOS)>>>
tradingAccount.validateMarginRequirement(
ctx.requiredMarginUsdX18,
tradingAccount.getMarginBalanceUsd(accountTotalUnrealizedPnlUsdX18),
ctx.orderFeeUsdX18.add(ctx.settlementFeeUsdX18)
);
}
----------------------------------------------------------------------
@audit>>> No try and catch--offchain can revert here/reversion in loop during execution(DOS)>>>
(ctx.newOpenInterestX18, ctx.newSkewX18) =
perpMarket.checkOpenInterestLimits(sizeDeltaX18, ctx.oldPositionSizeX18, ctx.newPositionSizeX18);
Impact
1. **Delayed Execution**: Valid orders may be executed late, potentially filling orders at prices significantly different from the target price.
2. **Insolvency Risk**: The protocol could become insolvent if valid orders are consistently delayed or unexecuted.
Tools Used
- Manual Solidity code analysis
Recommendations
To mitigate this issue, implement an error emision and order skipping mechanism for batched offchain orders. Ensure that only orders meeting all criteria are met for onchain execution.
Consider nesting _fillOrder in a try and catch statement IN THE OFFCHAIN EXECUTION to prevent a complete DOS/revertion of all orders Knowing fully well that some orders are ok.
Explanation of Changes
-
Error Handling with try-catch
: The _fillOrder
function call is now wrapped in a try-catch
block. If _fillOrder
fails, an OrderError
event is emitted, and the loop continues to the next order.
-
**Reverting Changes on Failure **: Reversion for ONCHAIN order reverts all actions when a reversion occurs during execution thus If _fillOrder
fails for offchain:
This ensures that a failure in _fillOrder
does not revert the entire iteration and allows other orders to be processed.
function fillOffchainOrders(
uint128 marketId,
OffchainOrder.Data[] calldata offchainOrders,
bytes calldata priceData
)
external
onlyOffchainOrdersKeeper(marketId)
{
FillOffchainOrders_Context memory ctx;
SettlementConfiguration.Data storage settlementConfiguration =
SettlementConfiguration.load(marketId, SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID);
PerpMarket.Data storage perpMarket = PerpMarket.load(marketId);
GlobalConfiguration.Data storage globalConfiguration = GlobalConfiguration.load();
(ctx.bidX18, ctx.askX18) =
settlementConfiguration.verifyOffchainPrice(priceData, globalConfiguration.maxVerificationDelay);
for (uint256 i; i < offchainOrders.length; i++) {
ctx.offchainOrder = offchainOrders[i];
-- if (ctx.offchainOrder.sizeDelta == 0) {
-- revert Errors.ZeroInput("offchainOrder.sizeDelta");
-- }
++ if (ctx.offchainOrder.sizeDelta == 0) {
++ emit OrderError(i, "Zero sizeDelta");
++ continue;
++ }
-- TradingAccount.Data storage tradingAccount =
-- TradingAccount.loadExisting(ctx.offchainOrder.tradingAccountId);
++
++ TradingAccount.Data storage tradingAccount;
++ try TradingAccount.loadExisting(ctx.offchainOrder.tradingAccountId) returns
++ (TradingAccount.Data storage account) {
++ tradingAccount = account;
++ } catch {
++ emit OrderError(i, "Non-existent trading account");
++ continue;
}
if (marketId != ctx.offchainOrder.marketId) {
-- revert Errors.OrderMarketIdMismatch(marketId, ctx.offchainOrder.marketId);
++ emit OrderError(i, "Order marketId mismatch");
++ continue;
}
if (ctx.offchainOrder.nonce != tradingAccount.nonce) {
-- revert Errors.InvalidSignedNonce(tradingAccount.nonce, ctx.offchainOrder.nonce);
++ emit OrderError(i, "Invalid signed nonce");
++ continue;
}
ctx.structHash = keccak256(
abi.encode(
Constants.CREATE_OFFCHAIN_ORDER_TYPEHASH,
ctx.offchainOrder.tradingAccountId,
ctx.offchainOrder.marketId,
ctx.offchainOrder.sizeDelta,
ctx.offchainOrder.targetPrice,
ctx.offchainOrder.shouldIncreaseNonce,
ctx.offchainOrder.nonce,
ctx.offchainOrder.salt
)
);
if (tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash]) {
-- revert Errors.OrderAlreadyFilled(ctx.offchainOrder.tradingAccountId, ctx.offchainOrder.salt);
++ emit OrderError(i, "Order already filled");
++ continue;
}
ctx.signer = ECDSA.recover(
_hashTypedDataV4(ctx.structHash), ctx.offchainOrder.v, ctx.offchainOrder.r, ctx.offchainOrder.s
);
if (ctx.signer != tradingAccount.owner) {
-- revert Errors.InvalidOrderSigner(ctx.signer, tradingAccount.owner);
++ emit OrderError(i, "Invalid order signer");
++ continue;
}
ctx.isBuyOrder = ctx.offchainOrder.sizeDelta > 0;
ctx.indexPriceX18 = ctx.isBuyOrder ? ctx.askX18 : ctx.bidX18;
ctx.fillPriceX18 = perpMarket.getMarkPrice(sd59x18(ctx.offchainOrder.sizeDelta), ctx.indexPriceX18);
ctx.isFillPriceValid = (ctx.isBuyOrder && ctx.offchainOrder.targetPrice <= ctx.fillPriceX18.intoUint256())
|| (!ctx.isBuyOrder && ctx.offchainOrder.targetPrice >= ctx.fillPriceX18.intoUint256());
if (!ctx.isFillPriceValid) {
++ emit OrderError(i, "Invalid fill price");
continue;
}
if (ctx.offchainOrder.shouldIncreaseNonce) {
unchecked {
tradingAccount.nonce++;
}
}
tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash] = true;
-- _fillOrder(
-- ctx.offchainOrder.tradingAccountId,
-- marketId,
-- SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID,
-- sd59x18(ctx.offchainOrder.sizeDelta),
-- ctx.fillPriceX18
-- );
++ try this._fillOrder(
++ ctx.offchainOrder.tradingAccountId,
++ marketId,
++ SettlementConfiguration.OFFCHAIN_ORDERS_CONFIGURATION_ID,
++ sd59x18(ctx.offchainOrder.sizeDelta),
++ ctx.fillPriceX18
++ ) {
++
++ } catch {
++ emit OrderError(i, "Failed to fill order");
++
++ tradingAccount.hasOffchainOrderBeenFilled[ctx.structHash] = false;
++
++ if (ctx.offchainOrder.shouldIncreaseNonce) {
++ unchecked {
++ tradingAccount.nonce--;
++ }
}
}
}
}