DittoETH

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

Cancelled order can be cancelled again and break the linked list

Summary

OrdersFacet.sol:cancelOrderFarFromOracle() does not check if the order to be cancelled is already cancelled or matched, making it possible to cancel an order multiple times and causing wrong assignations in the orders linked list.

Vulnerability Details

cancelOrderFarFromOracle function can be called by anyone to cancel the last order of a side (bid, ask, or short). This function checks that the order to be cancelled is the last in the linked list by asserting that nextId == Constants.TAIL. However, this check is not enough, as nextId is not reset when an order is cancelled. This means that when the last order is cancelled, it will still have nextId == Constants.TAIL.

Let's see this with an example. Suppose we have the following linked list of orders:

HEAD <- 100(c) <- HEAD <-> 101 <-> .. <-> 65100 <-> 65101 -> TAIL

If we cancel the last order, we will have:

HEAD <- 100(c) <- 65101(c) <- HEAD <-> 101 <-> .. <-> 65100 -> TAIL

Although nextId for cancelled orders is not relevant, it still points to the value it had before being cancelled. So, in this case, the order with id 65101 will have nextId == TAIL. This means that if we call cancelOrderFarFromOracle with lastOrderId = 65101, the function will not revert, as nextId == TAIL. This will cause several issues in the linked list:

65101(c) <- 65101(c) <- HEAD <-> 101 <-> .. <-> 65100 -> TAIL

As the prevId of the cancelled order is set to HEAD.prevId, in this case the cancelled order prevId will point to itself, and all the other cancelled orders are unlinked from the linked list.

Now, let's see what happens if we create a new order:

.. <-> 200 <-> 65101 <- HEAD <-> 101 <-> .. <-> 200 <-> 65101 <-> 201 <-> .. <-> 65100 -> TAIL

The new order is inserted in the linked list reusing the id of the last cancelled order. However, as prevId of the cancelled order points to itself, HEAD.prevId will also point to 65101. This means that new orders will reuse the ids of active orders.

Impact

This vulnerability can lead to multiple issues once the orderId of an asset reaches 65000, which can also be reached artificially by a bad actor.

  • Active orders can be overwritten by new orders.

  • Cancelled orders can be left out of the linked list, not being available for future use, which in turn can lead to not having available ids for new orders.

Given that the linked list can have more than one order pointing to the same id, the protocol is effectively broken as the predicted behavior of the linked list is not met anymore.

Proof of Concept

Add the following code snippet into test/CancelOrder.t.sol and run forge test --mt testCancelOrderAlreadyCancelled.

function testCancelOrderAlreadyCancelled() public {
setOrderIdAndMakeOrders({orderType: O.LimitShort});
diamond.cancelOrderFarFromOracle({
asset: asset,
orderType: O.LimitShort,
lastOrderId: 64999,
numOrdersToCancel: 1
});
diamond.cancelOrderFarFromOracle({
asset: asset,
orderType: O.LimitShort,
lastOrderId: 64998,
numOrdersToCancel: 1
});
diamond.cancelOrderFarFromOracle({
asset: asset,
orderType: O.LimitShort,
lastOrderId: 64998,
numOrdersToCancel: 1
});
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, receiver);
assertEq(diamond.getShortOrder(asset, 64998).addr, receiver);
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT, extra);
assertEq(diamond.getShortOrder(asset, 64998).addr, extra);
}

Tools Used

Manual inspection.

Recommendations

File: contracts/facets/OrdersFacet.sol
if (msg.sender == LibDiamond.diamondStorage().contractOwner) {
if (
orderType == O.LimitBid
+ && s.bids[asset][lastOrderId].orderType == O.LimitBid
&& s.bids[asset][lastOrderId].nextId == Constants.TAIL
) {
s.bids.cancelManyOrders(asset, lastOrderId, numOrdersToCancel);
} else if (
orderType == O.LimitAsk
+ && s.asks[asset][lastOrderId].orderType == O.LimitAsk
&& s.asks[asset][lastOrderId].nextId == Constants.TAIL
) {
s.asks.cancelManyOrders(asset, lastOrderId, numOrdersToCancel);
} else if (
orderType == O.LimitShort
+ && s.shorts[asset][lastOrderId].orderType == O.LimitShort
&& s.shorts[asset][lastOrderId].nextId == Constants.TAIL
) {
s.shorts.cancelManyOrders(asset, lastOrderId, numOrdersToCancel);
} else {
revert Errors.NotLastOrder();
}
} else {
//@dev if address is not DAO, you can only cancel last order of a side
if (
orderType == O.LimitBid
+ && s.bids[asset][lastOrderId].orderType == O.LimitBid
&& s.bids[asset][lastOrderId].nextId == Constants.TAIL
) {
s.bids.cancelOrder(asset, lastOrderId);
} else if (
orderType == O.LimitAsk
+ && s.asks[asset][lastOrderId].orderType == O.LimitAsk
&& s.asks[asset][lastOrderId].nextId == Constants.TAIL
) {
s.asks.cancelOrder(asset, lastOrderId);
} else if (
orderType == O.LimitShort
+ && s.shorts[asset][lastOrderId].orderType == O.LimitShort
&& s.shorts[asset][lastOrderId].nextId == Constants.TAIL
) {
s.shorts.cancelOrder(asset, lastOrderId);

An alternative fix would be performing the check in the cancelOrder function of LibOrders.sol:

File: contracts/libraries/LibOrders.sol
function cancelOrder(
mapping(address => mapping(uint16 => STypes.Order)) storage orders,
address asset,
uint16 id
) internal {
+ if (orders[asset][id].orderType == O.Cancelled || orders[asset][id].orderType == O.Matched) {
+ revert Errors.NotActiveOrder();
+ }
// save this since it may be replaced
uint16 prevHEAD = orders[asset][Constants.HEAD].prevId;
Updates

Lead Judging Commences

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

finding-436

shaka Submitter
almost 2 years ago
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.