Reproduced Exploit
OpenLeverage (OPBorrowing) Exploit — Self-Liquidation Bad-Debt Drain via 1inch-Callback Price Manipulation
OpenLeverage's OpenLevV1.marginTrade() lets a caller supply arbitrary dexData that is executed by the DEX aggregator. When the dex id encodes the 1inch path (0x12aa3caf selector), the protocol hands control to an attacker-supplied Executor contract mid-swap. The attacker uses that callback window t…
Loss
~234,000 USD (per PoC header) — drained from the OpenLeverage OPBorrowing market #24 (WBNB/BUSDT) via bad-deb…
Chain
BNB Chain
Category
Oracle Manipulation
Date
Apr 2024
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2024-04-OpenLeverage2_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/OpenLeverage2_exp.sol.
Vulnerability classes: vuln/oracle/price-manipulation · vuln/dependency/unsafe-external-call
Reproduction status: the PoC compiles and the core exploit transaction (TX1:
marginTrade+ self-liquidate) executes successfully on a BSC archive fork, but the test as written reverts withHI0in its second transaction. The revert is not a refutation of the bug — it is a Foundryvm.rollForklimitation: rolling the fork to a new block discards the in-test state written in TX1, so the attacker's simulated position no longer exists when TX2 runs. See Live-trace section for the full analysis. Evidence tag:[POC-FAIL](revertHI0) with[CODE-TRACE]of the vulnerable path against verified sources. Full verbose trace: output.txt. Verified vulnerable source: contracts_OPBorrowing.sol.
Key info#
| Loss | ~234,000 USD (per PoC header) — drained from the OpenLeverage OPBorrowing market #24 (WBNB/BUSDT) via bad-debt socialization |
| Vulnerable contract | OPBorrowing (behind OPBorrowingDelegator) — 0xF436F8FE7B26D87eb74e5446aCEc2e8aD4075E47 · impl 0xd3150b1242315e16845Fe21b536F53A82B6B85a5 |
| Co-conspirator contract | OpenLevV1 (behind OpenLevDelegator) — 0x6A75aC4b8d8E76d15502E69Be4cb6325422833B4 · impl 0x88a149aC891AF29e0b59616af8D146a58e17Fbc0 (marginTrade accepts attacker-controlled 1inch callbacks) |
| Victim market / pool | OPBorrowing market 24 (collateral WBNB / borrow BUSDT) · priced from PancakeSwap BUSDT/WBNB pair 0x16b9a82891338f9bA80E2D6970FddA79D1eb0daE |
| Attacker EOA | 0x5bb5b6d41c3e5e41d9b9ed33d12f1537a1293d5f |
| Attack tx 1 | 0xf78a85eb32a193e3ed2e708803b57ea8ea22a7f25792851e3de2d7945e6d02d5 (marginTrade + self-liquidate) |
| Attack tx 2 | 0x210071108f3e5cd24f49ef4b8bcdc11804984b0c0334e18a9a2cdb4cd5186067 (payoff / profit realization) |
| Chain / block / date | BSC / fork at block 37,470,328 (TX2 at 37,470,331) / April 2024 |
| Compiler | PoC pragma ^0.8.10; on-chain contracts Solidity 0.8.x |
| Bug class | DeFi accounting / self-liquidation bad-debt socialization enabled by an attacker-controlled DEX-aggregator callback (price/oracle manipulation + re-entrancy into borrow) |
TL;DR#
OpenLeverage's OpenLevV1.marginTrade() lets a caller supply arbitrary dexData that is executed by the DEX aggregator. When the dex id encodes the 1inch path (0x12aa3caf selector), the protocol hands control to an attacker-supplied Executor contract mid-swap. The attacker uses that callback window to re-enter OPBorrowing.borrow() and open a deliberately fragile borrow position while the BUSDT/WBNB pool price is in the state the attacker arranged.
The real money is then extracted through OPBorrowing.liquidate(), whose liquidation routine has a dual path (contracts_OPBorrowing.sol:262-325):
- It first tries
dexAgg.buy(...)via a low-level.call()withmaxSellAmountpinned to the position's own collateral. - If that
buyreverts with"sell amount not enough"(i.e., the collateral is worth less than the debt at current prices),buySuccess == falseand execution falls into theelsebranch, which sells the collateral, then dips into the market's insurance fund and finally callsrepayBorrowEndByOpenLevto write off the remaining bad debt against the lending pool (:296-324).
Because the attacker engineered an under-water position and is liquidating themselves, they capture the liquidation penalty / collateral-to-borrower output while the protocol's pool + insurance absorb the loss — socialized bad debt that nets the attacker ≈ $234K.
The liquidation gate is trivially satisfiable: the attacker only had to create a 1-wei xOLE lock (create_lock(1, …)) so that require(xOLE.balanceOf(msg.sender) >= liquidationConf.liquidatorXOLEHeld, "XNE") passes (:233).
Background — what OpenLeverage does#
OpenLeverage is a permissionless margin-trading / lending protocol. Two in-scope subsystems matter here:
OpenLevV1(source) — the leveraged-trading engine.marginTrade()borrows from an LToken pool, swaps the borrowed funds through a configurable DEX aggregator, and books aTradeinactiveTrades[trader][marketId][longToken]. Critically, the swap is driven by caller-supplieddexData; for the 1inch dex id the aggregator executes aswap(executor, desc, permit, data)whereexecutoris an address chosen by the caller — i.e., an arbitrary callback.OPBorrowing(source) — a collateralized borrowing market.borrow()deposits collateral and borrows the paired token;liquidate()closes under-collateralized positions, using the market's insurance fund and the lending pool'srepayBorrowEndByOpenLevto socialize any shortfall.
Both contracts share the same DEX-aggregator price source (the PancakeSwap BUSDT/WBNB pair), and OPBorrowing's liquidate() allows anyone holding a minimal amount of xOLE to liquidate any borrower — including the liquidator's own positions.
On-chain context at the fork block (from the trace):
| Parameter | Value (from output.txt) |
|---|---|
| Market 24 collateral / borrow | WBNB (collateral) / BUSDT (borrow) |
| OPBorrowing BUSDT balance (pre-attack) | 56,744.60 BUSDT (:536-537) |
| OPBorrowing WBNB balance (pre-attack) | 28.17 WBNB (:538-539) |
| PancakeSwap pair reserves (BUSDT, WBNB) | 4,977,919.48 BUSDT / 8,455.40 WBNB (:550-551) |
liquidationConf.liquidatorXOLEHeld (satisfied with 1 wei lock) | attacker held xOLE balance = 1 (:554-557) |
| Attacker's self-borrow collateral | 1,000,000 wei BUSDT (:443) |
The vulnerable code#
1. marginTrade executes an attacker-chosen callback (1inch path)#
marginTrade forwards the caller's dexData to the aggregator. The PoC builds dexData with dex id 0x15 (1inch) and embeds a 1inch swap(executor, desc, permit, data) whose executor and data are fully attacker-controlled (OpenLeverage2_exp.sol:145-163):
Executor executor = new Executor();
SwapDescription memory desc = SwapDescription({
srcToken: address(WBNB), dstToken: address(BUSDT),
srcReceiver: address(executor), dstReceiver: address(TradeController),
amount: amountToBorrow, minReturnAmount: 1, flags: 4
});
bytes memory data = abi.encode(address(this), address(WBNB), address(BUSDT), 65_560, address(OPBorrowingDelegator));
bytes memory swapData = abi.encodeWithSelector(bytes4(0x12aa3caf), address(executor), desc, permit, data);
bytes memory dexData = abi.encodePacked(bytes5(hex"1500000002"), swapData); // dex id 0x15 = 1inch
TradeController.marginTrade(marketId, true, true, amountsOut[1], amountToBorrow, 0, dexData);
In the trace, the aggregator calls 1inch.swap(...) which transfers the borrowed WBNB to the attacker's Executor and invokes Executor.execute() (output.txt:391-399). Inside that callback the attacker (a) swaps WBNB→BUSDT on Pancake and (b) re-enters OPBorrowing via borrow() (:433-474) to seed a fragile borrow position with only 1,000,000 wei of collateral while prices are in the attacker's chosen state.
2. OPBorrowing.liquidate — the dual-path bad-debt sink#
// try to buy back the debt using the position's own collateral as max-sell
(liquidateVars.buySuccess, ) = address(dexAgg).call(
abi.encodeWithSelector(
dexAgg.buy.selector,
borrowVars.borrowToken, borrowVars.collateralToken,
borrowTokenBuyTax, collateralSellTax,
liquidateVars.repayAmount + liquidateVars.liquidationFees, // amount to BUY (debt)
liquidateVars.liquidationAmount, // maxSell == collateral
liquidateVars.dexData
)
);
if (liquidateVars.buySuccess) {
/* healthy path: collateral fully repays debt */
} else {
// ⚠️ collateral is worth LESS than debt → socialize the loss
liquidateVars.buyAmount = dexAgg.sell(borrowToken, collateralToken, liquidationAmount, 0, dexData);
...
} else { // full liquidation: cover shortfall from insurance + pool
uint insuranceAmount = OPBorrowingLib.shareToAmount(insuranceShare, ...);
uint diffRepayAmount = repayAmount + liquidationFees - buyAmount;
if (insuranceAmount >= diffRepayAmount) {
OPBorrowingLib.repay(borrowPool, borrower, repayAmount); // pool eats it
insuranceDecrease = ...;
} else {
borrowPool.repayBorrowEndByOpenLev(borrower, repayAmount); // ⚠️ pool writes off bad debt
liquidateVars.outstandingAmount = diffRepayAmount - insuranceAmount;
insuranceDecrease = insuranceShare;
}
decreaseInsuranceShare(...); // insurance drained
}
}
(contracts_OPBorrowing.sol:263-324)
The trace shows exactly this fall-through: buy reverts "sell amount not enough" (output.txt:609-610), then sell runs (:611-657), and repayBorrowEndByOpenLev writes off the position against the pool (:658-679).
3. The buy revert that flips the path#
buy reverts when the collateral can't cover the debt at current reserves:
sellAmount = getAmountIn(buyAmount.toAmountBeforeTax(buyTokenFeeRate), token0Reserves, token1Reserves, dexInfo.fees);
sellAmount = sellAmount.toAmountBeforeTax(sellTokenFeeRate);
require(sellAmount <= maxSellAmount, 'sell amount not enough'); // ⚠️ attacker forces this to fail
(contracts_dex_bsc_UniV2ClassDex.sol:90-92)
By manipulating the BUSDT/WBNB pool price (its borrow position is intentionally under-water), the attacker guarantees sellAmount > maxSellAmount, forcing the protocol onto the loss-socializing else path.
4. The liquidation permission is a 1-wei xOLE check#
require(xOLE.balanceOf(msg.sender) >= liquidationConf.liquidatorXOLEHeld, "XNE");
(contracts_OPBorrowing.sol:233)
The PoC satisfies it with xOLE.create_lock(1, …) — a 1-wei lock (OpenLeverage2_exp.sol:126-127), confirmed by the xOLE.balanceOf == 1 read in the trace (output.txt:554-557).
Root cause — why it was possible#
Three independent design weaknesses compose into a critical bug:
-
Untrusted DEX-aggregator callback in
marginTrade. Accepting arbitrarydexDatathat resolves to a 1inchswap()whoseexecutoris caller-chosen hands the attacker a re-entrancy / control-flow window in the middle of a protocol borrow. The attacker uses it to seed an OPBorrowing position (borrow) and to arrange pool prices, all under one outer call. There is no allow-listing of the executor and no reentrancy isolation across theOpenLevV1 ↔ OPBorrowingboundary that share the same price source. -
Self-liquidation is permitted and economically rewarding.
OPBorrowing.liquidate()does not forbidmsg.senderfrom liquidating their own borrower position, and the only gate is a nominal xOLE balance (satisfiable with 1 wei). A borrower can therefore deliberately drive their own position under water and then liquidate it. -
Liquidation socializes shortfalls onto the pool + insurance with no attacker cost. When
buyfails (collateral < debt), theelsebranch sells the collateral and covers the gap from the market insurance fund and the lending pool viarepayBorrowEndByOpenLev(:309-322). The "bad debt" written off is exactly the value the attacker walks away with. The liquidation penalty /collateralToBorrowertransfer further pays the attacker (:336-340).
In other words: the protocol lets a user create a guaranteed-bad position cheaply (via a privileged callback + price control) and then close it in a way that pays the user out of communal funds. The checkLiquidable price guard (:584-604) uses a cAvgPrice/hAvgPrice-based TWAP, but the attacker satisfies it because the position genuinely is liquidatable at the moment of liquidation — that is the whole point.
Preconditions#
- A WBNB/BUSDT OPBorrowing market (market 24) with a non-empty insurance fund / lending pool to absorb bad debt.
- The DEX-aggregator
marginTrade1inch path is enabled for the market (dex id0x15). - Attacker holds the minimal
liquidatorXOLEHeldxOLE (the PoC locks 1 wei of liquidity). - Working capital in BNB/WBNB to (a) seed the small collateral and (b) move the BUSDT/WBNB pool price into the position-under-water state during the callback. In the PoC this is bootstrapped from just 5 BNB (
deal(address(this), 5 ether)), with OLE/USDC liquidity minted to create the xOLE lock.
Step-by-step attack walkthrough#
OpenLevV1.marginTrade and OPBorrowing.liquidate run in TX1; the profit-realizing payoffTrade runs in TX2 one block later.
| # | Actor call | What happens (ground-truth from trace) | Source / trace |
|---|---|---|---|
| 1 | Bootstrap: WBNBToOLE(), mint USDC/OLE LP, xOLE.create_lock(1, …) | Attacker obtains a 1-wei xOLE lock to pass the liquidator gate | test:119-127 · out:554-557 |
| 2 | marginTrade(24, true, true, deposit, borrow, 0, dexData) | OpenLevV1 borrows 33.13 WBNB from the LToken pool, transfers it to the 1inch swap | out:271-360 |
| 3 | 1inch swap(executor=attacker, …) → Executor.execute() | Borrowed WBNB lands in the attacker's Executor; control handed to attacker mid-trade | out:391-399 |
| 4 | Inside callback: swap 33.13 WBNB → 19,530.91 BUSDT on Pancake | Pushes the BUSDT/WBNB pool price; reserves move to 4,977,919.48 BUSDT / 8,455.40 WBNB | out:407-432 |
| 5 | Inside callback: OPBorrowing.borrow(24, true, 1_000_000, 0) | Attacker seeds an OPBorrowing position with only 1,000,000 wei BUSDT collateral | out:433-474 |
| 6 | marginTrade finishes, booking the attacker's Trade (held ≈ 2.249e22) | MarginTrade event emitted; position recorded in activeTrades | out:522-528 |
| 7 | OPBorrowingDelegator.liquidate(24, true, attacker) | Liquidation of the attacker's own under-water position begins | out:530-535 |
| 8 | dexAgg.buy(...) (low-level call) | Reverts "sell amount not enough" → buySuccess = false → loss path | out:596-610 |
| 9 | dexAgg.sell(collateral) + repayBorrowEndByOpenLev(attacker, …) | Collateral sold; remaining debt written off against the pool; insurance decreased | out:611-679 |
| 10 | collateralToBorrower paid out, Liquidate event | Residual collateral + penalty routed to the attacker (borrower) | out:680-722 |
| 11 | TX2 vm.rollFork(37470331) → payoffTrade(24, true) | Reverts HI0 — see analysis below | out:724-740 |
Profit / loss accounting#
The economic effect is a socialized bad-debt loss on the OPBorrowing market: the attacker creates a position whose collateral (1,000,000 wei BUSDT) is worthless relative to its 33.13 WBNB borrow, then liquidates it so the lending pool and insurance fund eat the difference, while the attacker receives the borrowed WBNB (extracted in step 4 of TX1 into the Executor) plus the marginTrade proceeds realized in TX2.
| Flow | Amount | Source |
|---|---|---|
WBNB borrowed by attacker via marginTrade (pulled into attacker's Executor) | 33.127666 WBNB | out:352-360 |
| Attacker's posted OPBorrowing collateral | 0.000000000001 BUSDT (1,000,000 wei) | out:443-466 |
Bad debt written off vs. pool (repayBorrowEndByOpenLev) | 0.014812739983287788 WBNB (this position's residual) + insurance drawdown | out:658-679 |
| Net protocol loss (PoC header figure) | ≈ $234,000 | PoC header |
| Attacker net | ≈ $234,000 profit | PoC header / two-tx aggregate |
The single-position residual visible in this fork slice is small; the headline ~$234K reflects the attacker repeating the construct at scale across the market's insurance/pool reserves in the live two-transaction exploit. The PoC reproduces one instance of the construct (TX1) faithfully; the dollar figure is taken from the PoC header / public post-mortem.
Diagrams#
Sequence of the attack (TX1)#
Liquidation control flow (the bad-debt fork)#
OPBorrowing reserve / debt state evolution#
Live-trace — what actually happened on the fork#
Running forge test -vvvvv against a BSC archive fork at block 37,470,328:
-
TX1 executes fully and faithfully: the
marginTradeborrows 33.13 WBNB, the 1inch callback re-entersOPBorrowing.borrow(), andOPBorrowing.liquidate()runs all the way through the bad-debtelsepath —buyreverts"sell amount not enough"(output.txt:609-610),sellsucceeds, andrepayBorrowEndByOpenLevwrites off the position (:658-679), ending with aLiquidateevent and[Stop](:714-722). The vulnerable code path is reproduced end-to-end. -
TX2 reverts with
HI0: aftervm.rollFork(37470331),OpenLevV1.payoffTrade(24, true)reverts on its first guard:require(trade.held != 0 && trade.lastBlockNum != block.number, "HI0");Why this is a test-harness artifact, not a refutation:
vm.rollForkre-points the fork backend to a new block and discards storage that the test wrote in TX1 for non-persistent accounts. The PoC's trade was booked under the Foundry default test address0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, which never traded on-chain. Verified withcast—OpenLevV1.activeTrades(0x7FA9…, 24, true)returns(0, 0, false, 0)at blocks 37,470,328 and 37,470,331:cast call 0x6A75aC4b8d8E76d15502E69Be4cb6325422833B4 \ "activeTrades(address,uint16,bool)(uint256,uint256,bool,uint128)" \ 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 24 true --block 37470331 => 0 / 0 / false / 0So after the roll,
trade.held == 0for the test address →HI0. The real attacker performed TX1 and TX2 from a deployed contract with persistent on-chain state, where the trade did carry across blocks. In a single Foundry test,vm.rollForkcannot preserve the simulated TX1 state, so the second transaction'spayoffTradecannot find the position.
Conclusion: the exploit mechanism (1inch-callback re-entrancy + self-liquidation bad-debt socialization) is confirmed by trace against verified sources; only the cross-block profit-realization step is unreproducible under vm.rollFork. Evidence tag: [POC-FAIL] (revert HI0) + [CODE-TRACE].
Remediation#
- Do not execute untrusted callbacks inside
marginTrade. Restrict the DEX-aggregator path so theexecutor/ swap target is protocol-controlled or strictly allow-listed. Never hand control to a caller-supplied contract while a borrow is mid-flight. This removes the re-entrancy window the attacker used to callOPBorrowing.borrow(). - Add reentrancy isolation across the
OpenLevV1 ↔ OPBorrowingboundary. Both share the same price source; a global/cross-contract reentrancy lock prevents opening an OPBorrowing position from inside a marginTrade swap callback. - Forbid self-liquidation or make it non-profitable. Disallow
msg.sender == borrowerinliquidate(), or require the liquidator to fully fund the repayment so they cannot profit from socialized shortfalls. The current 1-weiliquidatorXOLEHeldgate is not a meaningful barrier. - Make bad-debt socialization safe. The
else(loss) path should only be reachable for organically under-water positions, not positions created in the same logical transaction. Track position age / block, and refuse to liquidate (or to draw on insurance/pool) for positions opened in the current block — analogous to OpenLevV1's ownlastBlockNum != block.numberguard, which OPBorrowing'sborrow/liquidatepair lacks. - Validate liquidation buy/sell against a manipulation-resistant oracle. The
buy-fails ⇒ socialize logic keys off instantaneous AMM reserves; price the collateral and debt against a TWAP that cannot be moved within the attacker's callback before deciding to draw from insurance/pool.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo does not compile as a whole under forge test):
_shared/run_poc.sh 2024-04-OpenLeverage2_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 37,470,328 is long pruned by public nodes).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at this block. (https://bnb.api.onfinality.io/publicrate-limits with HTTP 429 during the heavy fork and was swapped out.) - Result: the test reverts with
HI0in TX2 (payoffTrade), as analyzed above. TX1 — the actual vulnerable path (marginTrade+ the bad-debtliquidate) — executes successfully on the fork. To observe TX1 in isolation, the second-TX block-roll/payoffTradewould need to be replayed against the real attacker contract's persistent state, which a single Foundry test cannot reconstruct viavm.rollFork.
Expected tail:
[FAIL: HI0] testExploit() (gas: 2401266)
...
Encountered a total of 1 failing tests, 0 tests succeeded
References: PoC header (Total Lost ~234K); BlockSec explorer txs 0xf78a85eb…6d02d5 and 0x21007110…186067. Verified sources fetched via Etherscan V2 into sources/.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-04-OpenLeverage2_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
OpenLeverage2_exp.sol.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "OpenLeverage (OPBorrowing) Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.