OpenLeverage OpenLevV1 / OpBorrowing Shared-LToken Debt-Accounting Liquidation + payoffTrade Drain (2024-04-01)

  • Name: OpenLeverage OpenLevV1 / OpBorrowing shared-LToken debt-accounting liquidation + payoffTrade drain
  • Date: 2024-04-01
  • Protocol: OpenLeverage
  • Chain: BNB Chain
  • Category: lending / margin-trade accounting desynchronization / shared-borrow-ledger contamination / payoff-path collateral drain
  • Loss: public reports put the loss around $226k-$236k
  • Summary: OpenLeverage let its margin-trade controller and a separate OpBorrowing path interact with the same LToken debt ledger without one canonical invariant boundary around trade still exists, borrow still exists, and this collateral can still be released. Public analyses said the attacker first opened a margin position in OpenLevV1, then used an untrusted external-call path into OpBorrowing to create an additional low-collateral state that still touched the same borrow accounting. When that position was liquidated, the liquidation leg and repayBorrowEndByOpenLev() path cleared debt in the shared LToken book more aggressively than the surviving OpenLev trade record expected. In a later transaction, the attacker called payoffTrade(), which — unlike the stricter closeTrade() path — only required borrowBalanceStored(msg.sender) == 0 before deleting the trade and transferring out the full held collateral. That let the attacker cash out collateral from a trade state whose debt assumptions had already been desynchronized by the earlier liquidation sequence.
  • Trigger / entry point:
    • The vulnerable public surface sat in OpenLeverage’s BNB-chain trade controller at 0xF436F8FE7b26d87Eb74E5446acEc2e8AD4075E47 and the linked lending-pool LToken repayment path used by both OpenLev margin positions and the OpBorrowing module.
    • Public analyses highlighted a two-transaction exploit because both liquidate() and payoffTrade() required trade.lastBlockNum != block.number, so the attacker could not complete the whole sequence inside one same-block call chain.
    • The first leg used marginTrade() plus the auxiliary borrowing path to create a margin position whose debt state could later be changed through liquidation against the shared LToken ledger; public writeups described this as an extra position opened through an untrusted external call to OpBorrowing during the margin-trade flow.
    • The second leg used payoffTrade(), whose critical postcondition was only marketVars.buyPool.borrowBalanceStored(msg.sender) == 0; once that passed, the function deleted the live trade and transferred the full closeAmount back to the trader without re-running the stronger proportional-repayment checks present in closeTrade().
  • Root cause:
    • OpenLeverage did not keep one canonical accounting object binding together OpenLev trade state, OpBorrowing side effects, and the underlying LToken borrow ledger.
    • Public analyses placed the setup bug in the margin-trade flow’s willingness to let an untrusted external call create an additional OpBorrowing state against the same lender book.
    • The liquidation path could then reduce or clear debt through repayBorrowEndByOpenLev() in a way that was economically meaningful to the shared lender but not fully reflected in the remaining OpenLev trade state that payoffTrade() later trusted.
    • payoffTrade() then treated borrowBalanceStored(msg.sender) == 0 as sufficient proof that the whole trade was safely settled, even though the zero debt reading could have been reached through the earlier cross-module liquidation path rather than through an ordinary full close of the margin position.
    • Put differently: OpenLeverage had a shared debt book but not a shared close-out invariant. One module could mutate the borrow side of the position, and another module later released collateral as if that zero debt state proved the trade had reached its intended terminal form.
    • This makes the incident less about generic reentrancy than about cross-module debt-accounting desynchronization followed by a helper close-out path that failed to re-validate the full position.
  • Exploit path:
    1. The attacker funded BNB-chain EOAs from Tornado Cash and interacted with OpenLeverage’s trade controller 0xF436F8FE7b26d87Eb74E5446acEc2e8AD4075E47.
    2. They opened a margin position through OpenLevV1::marginTrade() and, according to public writeups, also created an additional low-collateral position through the protocol’s OpBorrowing integration that still relied on the same LToken borrow ledger.
    3. The attacker then forced the liquidation path to run against that state. In the under-repaid branch, liquidate() could call repayBorrowEndByOpenLev(owner, finalRepayAmount), which mutated the shared LToken debt state from the OpenLev side.
    4. Because both the margin-trade path and the auxiliary borrowing path leaned on the same lender accounting, that liquidation leg zeroed or materially reduced borrow state more broadly than the surviving OpenLev trade record expected.
    5. In a later transaction, the attacker called payoffTrade(). That function recalculated the trade’s held collateral, pulled in whatever repayment amount its own logic believed necessary, and then only enforced that borrowBalanceStored(msg.sender) == 0.
    6. Since the earlier liquidation sequence had already driven the lender-side borrow balance to zero, payoffTrade() treated the trade as fully settled, deleted the active trade, and transferred out the full remaining collateral.
    7. The attacker repeated the sequence across exploit legs cited by public analyses and exited with roughly $226k-$236k.
  • What the attacker needed to be true:
    • OpenLev margin positions and the auxiliary borrowing path had to share the same effective LToken debt ledger or repayment semantics.
    • The liquidation leg had to be able to mutate that debt ledger through repayBorrowEndByOpenLev() without also forcing one canonical trade-close invariant on the surviving OpenLev trade state.
    • payoffTrade() had to accept borrowBalanceStored(msg.sender) == 0 as sufficient settlement proof, instead of re-validating whether the remaining trade record and collateral state were still economically consistent.
    • The protocol needed enough real collateral still parked against the trade for the later payoffTrade() withdrawal to be worth calling.
    • The attacker needed the exploit sequence to span at least two transactions because OpenLeverage blocked same-block liquidation / payoff on the same trade.
  • Technical anatomy worth remembering:
    • This was not primarily an oracle-manipulation incident. The decisive failure sat in internal debt accounting and close-out semantics.
    • It is a useful reminder that shared lender balance does not automatically mean shared economic invariant. If two modules can both touch the same debt book, then the trade state that guards collateral release must be reconciled against every debt-mutating path, not only against the happy-path close flow.
    • The dangerous read in payoffTrade() was conceptually narrow: once stored borrow balance reached zero, the function proceeded to delete the trade and hand back collateral. In the same contract, closeTrade() was stricter: it computed the current borrow, repaid against that amount, and enforced repayed >= borrowed * closeRatio / 1e18 before allowing the trade to settle. payoffTrade() skipped that stronger settlement boundary and let the lender’s stored debt state stand in for the entire trade-settlement truth.
    • OpenLeverage’s same-block guard did not save it; it only forced the attacker to serialize the exploit across transactions.
    • The incident preserves a sharp margin-protocol cousin of broader helper-path invariant failures: one path changed debt state, another path released collateral, and no single canonical close boundary forced those views back into sync before funds moved.
  • Representative transactions and artifacts:
    • Vulnerable OpenLeverage trade controller: 0xF436F8FE7b26d87Eb74E5446acEc2e8AD4075E47
    • Attacker address highlighted by SolidityScan: 0x5bb5b6d41c3e5e41d9b9ed33d12f1537a1293d5f
    • Exploit txs cited by HashDit: 0xf08b6d36dc6f650c030344b6307ae94528f77a01db11d1284ed966e7e44337d3, 0x0434bf08a63d70b84fad033d38d8b645498016b3931af4d13f6c11e86848876b
    • Additional exploit legs highlighted by SolidityScan: 0xf78a85eb32a193e3ed2e708803b57ea8ea22a7f25792851e3de2d7945e6d02d5, 0x210071108f3e5cd24f49ef4b8bcdc11804984b0c0334e18a9a2cdb4cd5186067
    • Key functions preserved in public analyses and source review: marginTrade(), liquidate(), repayBorrowEndByOpenLev(), payoffTrade()
  • Response / mitigation:
    • OpenLeverage publicly acknowledged the incident, stated that no user wallet assets were affected directly, and paused protocol trading and lending while investigating.
    • Public writeups converged on the same repair theme: debt mutations across OpenLevV1, OpBorrowing, and LToken needed one canonical settlement boundary so that liquidation, repayment, and collateral-release helpers could not observe partially reconciled state.
    • The immediate practical mitigation was to stop the affected markets and rework the accounting assumptions around the shared borrower ledger before resuming operations.

Pattern tags

  • Cross-module debt-accounting desynchronization
  • Helper-path invariant reuse failure
  • Shared-lender-ledger contamination
  • Solvency-check bypass / stale settlement proof
  • Liquidation-side debt erasure followed by collateral release

Why the exploit worked

  • OpenLevV1, OpBorrowing, and the underlying LToken book were allowed to mutate one borrower’s economic position without one shared position is fully settled object.
  • The liquidation path could drive lender-side debt to zero through repayBorrowEndByOpenLev() while the surviving trade record still looked locally live and still held collateral worth withdrawing.
  • payoffTrade() used a weaker settlement test than closeTrade(): it trusted borrowBalanceStored(msg.sender) == 0 instead of proving that the remaining trade, collateral, and repayment history still matched one canonical closeout path.
  • Because the protocol’s stricter repayment logic lived in a sibling close path rather than in a shared settlement core, the attacker could first create the broken state through liquidation and then cash it out through the weaker payoff helper.
  • Same-block guards only serialized the exploit into multiple transactions; they did not repair the fact that debt-zero in the lender book had become a weaker object than full trade-settlement truth.

Detection and audit pivots

  • Trace every path that can mutate the same LToken debt ledger and ask whether each one also retires or reconciles the corresponding OpenLev trade object before collateral can move.
  • Diff helper closeout paths against the strict path: here the memorable audit cue is closeTrade() requiring proportional repayment while payoffTrade() only requires a stored zero-debt read.
  • Alert on positions where lender-side debt becomes zero immediately after liquidation while the margin-trade record remains active and still controls collateral release rights.
  • Review all external-call or auxiliary-borrowing hooks inside trade-open flows for ways they can create additional debt state against the same lender book without also extending the same position-settlement invariants.
  • Treat multi-transaction same-user sequences marginTrade/open auxiliary borrow -> liquidation -> payoffTrade as one economic flow in monitoring, not as unrelated calls that each look locally acceptable.

Failure-prevention checklist

  • Collapse closeTrade(), payoffTrade(), liquidation repayment, and auxiliary borrowing settlement onto one shared position-close core that proves the post-state trade, debt, and collateral view are mutually consistent before any collateral is released.
  • Do not let lender-side borrowBalanceStored == 0 authorize collateral release by itself; require proof that the zero debt arose through the canonical closeout or liquidation-completion path for that exact trade.
  • Bind every debt mutation to one position identity: if OpBorrowing and OpenLevV1 can touch the same LToken ledger, their updates must also reconcile one shared position-settlement object rather than only a shared borrower address balance.
  • Make helper paths narrower than the strict path, not weaker. If payoffTrade() exists for convenience, it should inherit closeTrade()’s repayment and post-state checks or revert once prior liquidation has changed the accounting assumptions.
  • Add adversarial tests for open trade -> auxiliary borrow -> liquidation -> payoff helper, including multi-transaction sequences where same-block guards are satisfied but trade-state drift still exists.
  • Emit and monitor explicit reconciliation events when a liquidation repays debt for a position whose collateral-release rights remain live somewhere else in the protocol.

Audit prompts

  • What exact object proves this trade is fully settled: a borrower-level lender balance, a trade-level state machine, or both together? If the answer is fuzzy, the protocol is already relying on folklore.
  • Which functions can make lender debt read zero without also consuming the same trade-close right later used by payoffTrade(), withdraw, or other collateral-release helpers?
  • Where do auxiliary borrowing modules share a lender ledger with margin positions while authenticating different position identities, collateral sets, or closeout assumptions?
  • If liquidation partially or fully repays debt on behalf of a position, which invariant forces every later close or payoff path to observe that liquidation-adjusted state rather than an older local trade view?
  • Are there any convenience paths whose economic claim is only debt reads zero now rather than the position reached a canonical settled terminal state?

Lessons that should already be standard

  • A shared borrower ledger is not the same thing as a shared closeout invariant.
  • Debt reads zero is weaker than this exact leveraged position is safely settled.
  • If two modules can mutate one debt book, collateral release has to authenticate one position-settlement object, not just one borrower balance.
  • Convenience helpers become money-moving finality boundaries the moment they can delete positions and release collateral.
  • Same-block guards and multi-transaction friction are not defenses if the protocol still trusts a weaker state object on the next transaction.

Why this incident matters

  • OpenLeverage is a crisp example of cross-module accounting drift in a leveraged trading protocol rather than in a vanilla money market.
  • It shows how a protocol can keep each individual component looking locally reasonable while still losing money because no single invariant binds debt zero, trade closed, and collateral releasable together across all modules.
  • It is also a useful positive-design handoff into helper-path-invariant-reuse-and-fail-closed-discipline: once liquidation-side repayment and auxiliary borrowing could both mutate the same lender debt book, payoffTrade() should not have been allowed to treat borrowBalanceStored == 0 as whole-position truth unless one canonical closeout boundary had already reconciled the surviving trade state. The code contrast inside OpenLevV1 is the memorable part: closeTrade() still demanded proportional repayment evidence, while payoffTrade() released collateral on a much weaker debt reads zero condition.
  • It also now routes cleanly into collateral-liveness-and-right-consumption-discipline: the surviving trade record and its collateral-release right were still treated as fully live even after liquidation-side and auxiliary-borrowing-side mutations had already changed what backing and settlement meaning remained behind that record.
  • It also broadens the corpus of solvency / accounting incidents beyond classic reserve donations or emergency-withdraw bugs: here the exploitable seam was a shared lender ledger reused by multiple position-management surfaces.

Internal linkages

  • Core recurring pattern: solvency-check-bypass — OpenLeverage preserved the one path mutates debt state, another path later releases collateral against the broken ledger cousin.
  • Sharper design companion: helper-path-invariant-reuse-and-fail-closed-discipline — payoffTrade() should have reused one canonical trade-settlement boundary that remained correct after liquidation- and auxiliary-borrowing-side debt mutations, or failed closed once cross-module state had diverged.
  • Sharper liveness companion: collateral-liveness-and-right-consumption-discipline — the healthier question is what exact post-liquidation / post-borrow mutation should have retired the trade record’s authority to release collateral once the record no longer represented one fully live, settled position.
  • Adjacent design companion: exit-right-consumed-state-binding — not because this was a classic duplicate-withdraw replay, but because the dangerous question was still what exact closeout right had actually been consumed before collateral was allowed to leave? A lender-side zero-debt reading turned out to be a weaker object than full trade-settlement truth.

Control surface