How Was Open Leverage Exploited?

6 min read

Learn how Open Leverage was exploited, which resulted in a loss of assets worth $260,000.

TL;DR#

On April 1, 2024, Open Leverage was exploited across multiple transactions on the BNB chain and the Arbitrum network due to a smart contract vulnerability, which resulted in a loss of assets worth approximately $260,000.

Introduction to Open Leverage#

Open Leverage is a permissionless lending margin trading protocol that enables traders or other applications to long or short on any trading pair on DEXs. 

Vulnerability Assessment#

The root cause of the exploit is an inconsistency in the accounting process.

Steps#

Step 1:

We attempt to analyze one of the attack transactions executed by the exploiter.

Step 2:

In this attack transaction, the attacker targets the market with ID 15, using the marginTrade function of the OpenLevV1 contract to first establish a margin position.

function marginTrade(
  uint16 marketId,
  bool longToken,
  bool depositToken,
  uint deposit,
  uint borrow,
  uint minBuyAmount,
  bytes memory dexData
) external payable override nonReentrant onlySupportDex(dexData) returns (uint256) {
  return _marginTradeFor(msg.sender, marketId, longToken, depositToken, deposit, borrow, minBuyAmount, dexData);
}

Step 3:

The exploiter opens this position by depositing roughly 0.8 ETH and borrowing $18,000 worth of USDC to leverage, as the borrowed token also accounts for collateral in the position.

/// @notice Margin trade or just add more deposit tokens.
/// @dev To support token with tax and reward. Stores share of all token balances of this contract.
/// @param longToken Token to long. False for token0, true for token1.
/// @param depositToken Token to deposit. False for token0, true for token1.
/// @param deposit Amount of ERC20 tokens to deposit. WETH deposit is not supported.
/// @param borrow Amount of ERC20 to borrow from the short token pool.
/// @param minBuyAmount Slippage for Dex trading.
/// @param dexData Index and fee rate for the trading Dex.
function _marginTradeFor(address trader, uint16 marketId, bool longToken, bool depositToken, uint deposit, uint borrow, uint minBuyAmount, bytes memory dexData) internal returns (uint256 newHeld) {
  Types.TradeVars memory tv;
  Types.MarketVars memory vars = OpenLevV1Lib.toMarketVar(longToken, true, markets[marketId]);
  bytes memory calPriceDexData = OpenLevV1Lib.getCalPriceDexData(dexData, vars.dexs[0]);
  {
    Types.Trade storage t = activeTrades[trader][marketId][longToken];
    OpenLevV1Lib.verifyTrade(vars, longToken, depositToken, deposit, borrow, dexData, addressConfig, t, msg.sender == opLimitOrder ? false : true);
    (ControllerInterface(addressConfig.controller)).marginTradeAllowedV2(marketId, trader, longToken);
    if (dexData.isUniV2Class()) {
      updatePrice(address(vars.buyToken), address(vars.sellToken), calPriceDexData);
    }
  }

  tv.totalHeld = totalHelds[address(vars.buyToken)];
  tv.depositErc20 = depositToken == longToken ? vars.buyToken : vars.sellToken;

  deposit = transferIn(msg.sender, tv.depositErc20, deposit, msg.sender == opLimitOrder ? false : true);

  // Borrow
  uint borrowed;
  if (borrow > 0) {
    {
      uint balance = OpenLevV1Lib.balanceOf(vars.sellToken);
      vars.sellPool.borrowBehalf(trader, borrow);
      borrowed = OpenLevV1Lib.balanceOf(vars.sellToken).sub(balance);
    }

    if (depositToken == longToken) {
      (uint currentPrice, uint8 priceDecimals) = addressConfig.dexAggregator.getPrice(address(vars.sellToken), address(vars.buyToken), calPriceDexData);
      tv.borrowValue = borrow.mul(currentPrice).div(10 ** uint(priceDecimals));
    } else {
      tv.borrowValue = borrow;
    }
  }

  require(borrow == 0 || deposit.mul(10000).div(tv.borrowValue) > vars.marginLimit, "MAM");
  tv.fees = feesAndInsurance(
    trader,
    deposit.add(tv.borrowValue),
    address(tv.depositErc20),
    marketId,
    depositToken == longToken ? vars.reserveBuyToken : vars.reserveSellToken,
    totalHelds[address(tv.depositErc20)]
  );
  tv.depositAfterFees = deposit.sub(tv.fees);
  tv.dexDetail = dexData.toDexDetail();

  if (depositToken == longToken) {
    if (borrowed > 0) {
      tv.newHeld = flashSell(address(vars.buyToken), address(vars.sellToken), borrowed, minBuyAmount, dexData);
      tv.token0Price = longToken ? tv.newHeld.mul(1e18).div(borrowed) : borrowed.mul(1e18).div(tv.newHeld);
    }
    tv.newHeld = tv.newHeld.add(tv.depositAfterFees);
  } else {
    tv.tradeSize = tv.depositAfterFees.add(borrowed);
    tv.newHeld = flashSell(address(vars.buyToken), address(vars.sellToken), tv.tradeSize, minBuyAmount, dexData);
    tv.token0Price = longToken ? tv.newHeld.mul(1e18).div(tv.tradeSize) : tv.tradeSize.mul(1e18).div(tv.newHeld);
  }
  newHeld = tv.newHeld;
  Types.Trade storage trade = activeTrades[trader][marketId][longToken];
  tv.newHeld = OpenLevV1Lib.amountToShare(tv.newHeld, tv.totalHeld, vars.reserveBuyToken);
  trade.held = trade.held.add(tv.newHeld);
  trade.depositToken = depositToken;
  trade.deposited = trade.deposited.add(tv.depositAfterFees);
  trade.lastBlockNum = uint128(block.number);

  totalHelds[address(vars.buyToken)] = totalHelds[address(vars.buyToken)].add(tv.newHeld);

  require(isPositionHealthy(trader, true, OpenLevV1Lib.shareToAmount(trade.held, totalHelds[address(vars.buyToken)], OpenLevV1Lib.balanceOf(vars.buyToken)), vars, calPriceDexData), "PNH");

  emit MarginTrade(trader, marketId, longToken, depositToken, deposit, borrow, tv.newHeld, tv.fees, tv.token0Price, tv.dexDetail);
}

Step 4:

The attacker opens another position on the same market using an untrusted external call with a very small amount of collateral and no debt through the OpBorrowing contract.

Step 5:

As both the OpenLevV1 and OpBorrowing contracts relied on the LToken contract for their operations, the contract inherently suggested that the debt position of the first position was also cleared off along with the liquidation of this small position.

Step 6:

In the second attack transaction, the attacker took advantage of this situation using the payoffTrade function, which allowed them to break the health check and extract all the collateral from the margin positions.

/// @notice payoff trade by shares.
/// @dev To support token with tax, function expect to fail if share of borrowed funds not repayed.
/// @param longToken Token to long. False for token0, true for token1.
function payoffTrade(uint16 marketId, bool longToken) external payable override nonReentrant {
  Types.Trade storage trade = activeTrades[msg.sender][marketId][longToken];
  bool depositToken = trade.depositToken;
  uint deposited = trade.deposited;
  Types.MarketVars memory marketVars = OpenLevV1Lib.toMarketVar(longToken, false, markets[marketId]);

  //verify
  require(trade.held != 0 && trade.lastBlockNum != block.number, "HI0");
  (ControllerInterface(addressConfig.controller)).closeTradeAllowed(marketId);
  uint heldAmount = trade.held;
  uint closeAmount = OpenLevV1Lib.shareToAmount(heldAmount, totalHelds[address(marketVars.sellToken)], marketVars.reserveSellToken);
  uint borrowed = OpenLevV1Lib.borrowCurrent(marketVars.buyPool, msg.sender);

  //first transfer token to OpenLeve, then repay to pool, two transactions with two tax deductions
  uint24 taxRate = taxes[marketId][address(marketVars.buyToken)][0];
  uint firstAmount = Utils.toAmountBeforeTax(borrowed, taxRate);
  uint transferAmount = transferIn(msg.sender, marketVars.buyToken, Utils.toAmountBeforeTax(firstAmount, taxRate), true);
  OpenLevV1Lib.repay(marketVars.buyPool, msg.sender, transferAmount);

  require(marketVars.buyPool.borrowBalanceStored(msg.sender) == 0, "IRP");
  delete activeTrades[msg.sender][marketId][longToken];
  totalHelds[address(marketVars.sellToken)] = totalHelds[address(marketVars.sellToken)].sub(heldAmount);
  doTransferOut(msg.sender, marketVars.sellToken, closeAmount);

  emit TradeClosed(msg.sender, marketId, longToken, depositToken, heldAmount, deposited, heldAmount, 0, 0, 0);
}

Aftermath#

The team acknowledged the occurrence and stated that they had paused their protocol while the investigation was underway. According to them, the accumulated insurance and the buyback funds would be able to cover all aspects of their loss.

The exploit caused a loss of approximately $220,000 on BNB Chain and $40,000 on Arbitrum. Following the exploit, the team decided to discontinue the OpenLeverage trading and lending protocol. 

Solution#

To address the exploit detailed in the Open Leverage incident, a multifaceted approach is necessary to secure the smart contract environment, enhance the protocol's resilience against similar vulnerabilities, and rebuild user trust. Conducting thorough audits with multiple independent security firms is essential to ensure a diverse range of security practices and perspectives are considered, focusing particularly on the areas of the code that led to the exploit, especially the accounting inconsistencies and the handling of collateral and debt across contracts. Implementing a peer review system where code changes undergo rigorous reviews by multiple experienced developers before deployment can help catch potential issues that automated tools or a single pair of eyes might miss.

Reevaluating how state changes are managed between interconnected contracts such as OpenLevV1, OpBorrowing, and LToken is crucial. Ensuring that state changes in one contract correctly reflect across all relevant contracts without unintended consequences will help in minimizing direct interactions between critical contract functions and untrusted external calls, thus reducing the risk of exploits. Developing and integrating circuit breaker mechanisms that can automatically halt specific functions or the entire protocol in response to abnormal activity indicative of an exploit should be considered, allowing time for investigation and resolution without permanent disruption to users.

Introducing dynamic risk parameters that adjust collateral and borrowing limits based on market conditions and the health of the protocol, including more conservative thresholds for collateralization ratios and borrowing limits, is important for risk management. Upgrading the protocol's health check mechanisms to more accurately assess the risk of positions and the overall system involves real-time assessments that can detect and respond to abnormal patterns indicative of exploitation or market manipulation.

Even with robust security protocols in place, the risk of vulnerabilities being exploited remains. In such cases, the role of Neptune Mutual becomes invaluable. By establishing a dedicated cover pool with Neptune Mutual, the negative impacts of incidents similar to the Open Leverage exploit can be greatly reduced. Specializing in providing coverage for losses stemming from smart contract vulnerabilities, Neptune Mutual employs parametric policies tailored to these unique risks.

Working with Neptune Mutual streamlines the process of recovery for users by eliminating the extensive need for proof of loss documents. After an incident is confirmed and addressed through our comprehensive incident resolution process, we focus on quickly providing compensation and financial aid to those affected. This method ensures swift help for users impacted by such security flaws.

Our coverage spans several major blockchain networks, including EthereumArbitrum, and the BNB chain, offering widespread support to a variety of DeFi users. This extensive coverage helps us protect against a range of vulnerabilities, thereby increasing safety for our diverse clientele.

Reference Source BlockSec

By

Tags