Onyx Protocol oETH / NFTLiquidation Fake-oToken Substitution Self-Liquidation Drain (2024-09-26)

  • Name: Onyx Protocol oETH / NFTLiquidation fake-oToken substitution self-liquidation drain
  • Date: 2024-09-26
  • Protocol: Onyx Protocol
  • Chain: Ethereum
  • Category: liquidation-helper trust-boundary failure / fake integration endpoint substitution / self-liquidation reward inflation
  • Loss: about $3.8 million
  • Summary: Onyx was exploited a second time when an attacker combined two mistakes: first, they pushed their own oETH position into liquidatable shortfall by abusing the same Compound-family precision edge that let tiny mint / redeem loops distort exchange-rate accounting in low-liquidity conditions; second, they fed Onyx’s NFTLiquidation helper a counterfeit oTokenRepay, counterfeit underlying token, and counterfeit oTokenCollateral. Because liquidateWithSingleRepay() trusted caller-supplied market contracts by interface shape instead of proving they were canonical Onyx markets, the helper let the attacker choose an arbitrary repayAmount, treated almost all of it as extraRepayAmount, and then executed attacker-controlled callbacks during the fake repay-token transfer path. Inside that callback, the attacker performed a real oVUSD.liquidateBorrow(attacker, 1, oETH), seized the manipulated oETH collateral, redeemed it to ETH, repaid the flash loan, and kept roughly 4.1M VUSD, 7.35M XCN, 50K USDT, 5K DAI, and 0.23 WBTC worth about $3.8M.
  • Trigger / entry point:
    • The attacker launched the exploit in transaction 0x46567c731c4f4f7e27c4ce591f0aebdeb2d9ae1038237a0134de7b13e63d8729 after flash-borrowing 2,000 WETH from the Balancer Vault.
    • They used Onyx’s oETH market at 0x2CCb7d00a9E10D0c3408B5EEfb67011aBfaCb075 as the self-liquidation target and NFTLiquidation at 0xf10bc5be84640236c71173d1809038af4ee19002 as the trusted helper they would later poison with fake market addresses.
    • The decisive helper surface was NFTLiquidation.liquidateWithSingleRepay(borrower, oTokenCollateral, oTokenRepay, repayAmount), which only checked that borrower had shortfall and then trusted the supplied oTokenRepay / oTokenCollateral contracts for borrow-balance, underlying-asset, mint, and transfer behavior.
  • Root cause:
    • NFTLiquidation accepted attacker-supplied oTokenRepay and oTokenCollateral addresses without proving they were canonical Onyx markets from the Comptroller.
    • The helper’s decisive state machine was built entirely on that untrusted root object: it read borrowedAmount = IOErc20(oTokenRepay).borrowBalanceCurrent(borrower), derived extraRepayAmount = repayAmount - borrowedAmount, fetched underlying = IOErc20(oTokenRepay).underlying(), then approved and called back into the same caller-chosen oTokenRepay for liquidateBorrow() and mint().
    • Because the counterfeit repay market returned borrowedAmount = 0, almost the full caller-chosen repayAmount was reclassified as extraRepayAmount; because it pointed to a fake underlying token, transferFrom() and approve() could report success without moving real value; and because fake liquidateBorrow() / mint() returned success, the helper advanced as if a canonical repay-and-remint cycle had happened.
    • After that, the helper executed IERC20(oTokenRepay).transfer(borrower, IERC20(oTokenRepay).balanceOf(address(this))) before its final borrowBalanceCurrent(borrower) == 0 check and before resetting extraRepayAmount = 0, giving the counterfeit repay-token contract an attacker-controlled callback point inside a still-trusted liquidation flow.
    • Separately, the attacker could distort oETH exchange-rate accounting with tiny mint / redeem loops and direct ETH sends, creating a self-liquidatable account state in which repaying a tiny amount of real oVUSD debt seized economically large amounts of oETH collateral.
    • In short: Onyx let a liquidation helper reason over attacker-authored market fiction, and the attacker used that fictional wrapper to cash out a real self-liquidation opened by precision-manipulated collateral accounting.
  • Exploit path:
    1. The attacker EOA 0x680910cf5fc9969a25fd57e7896a14ff1e55f36b deployed a main exploit contract at 0xa57eda20be51ae07df3c8b92494c974a92cf8956 plus helper contracts including the oETH rate manipulator 0xae7d68b140ed075e382e0a01d6c67ac675afa223, a fake repay oToken 0x4f8b8c1b828147c1d6efc37c0326f4ac3e47d068, a fake underlying token 0x3f100c9e9b9c575fe73461673f0770435575dc0e, and a fake collateral oToken 0xad45812c62fcbc8d54d0cc82773e85a11f19a248.
    2. Using the Balancer flash loan, the attacker unwrapped WETH, minted oETH with roughly 1,999.5 ETH, entered Onyx markets, and borrowed deep-liquidity assets including XCN, DAI, WBTC, USDT, and VUSD against that collateral.
    3. The attacker then sent 0.5 ETH to the rate-manipulator helper and ran roughly 56 tiny oETH mint / redeemUnderlying() loops around values like 215,227,349 wei and 330,454,701 wei, using the known precision edge in low-liquidity cToken-style accounting to distort oETH exchange-rate state enough that their own account crossed into shortfall.
    4. Once the account was liquidatable, the attacker called NFTLiquidationProxy.liquidateWithSingleRepay(attacker, fake_oTokenCollateral, fake_oTokenRepay, 4_764_735_291_322).
    5. Inside liquidateWithSingleRepayFresh(), Onyx asked the fake repay market for borrowBalanceCurrent(attacker), got 0, set extraRepayAmount = repayAmount, accepted a fake underlying token whose transferFrom() always returned true, and accepted fake liquidateBorrow() / mint() success without any canonical-market check.
    6. The critical cash-out happened when Onyx reached IERC20(oTokenRepay).transfer(borrower, balanceOf(this)): the counterfeit repay token used that callback to approve real VUSD to real oVUSD, call oVUSD.liquidateBorrow(attacker, 1, oETH), receive seized oETH, redeem the seized oETH to ETH, and send the ETH back to the main attacker contract.
    7. The attacker then swapped part of the borrowed VUSD into WETH on Uniswap v3, rewrapped ETH, repaid Balancer’s 2,000 WETH, and transferred the remaining stolen assets to the EOA.
  • What the attacker needed to be true:
    • Onyx had to let a borrower liquidate through NFTLiquidation using caller-chosen market addresses rather than Comptroller-authenticated markets.
    • The helper had to trust interface-compatible return values from borrowBalanceCurrent(), underlying(), liquidateBorrow(), mint(), and transfer() without proving those contracts were real Onyx integrations.
    • The attacker needed a real borrow position that could be pushed into shortfall by oETH exchange-rate distortion.
    • A tiny real oVUSD liquidation had to seize meaningful oETH once the manipulated exchange-rate state made the borrower’s collateral appear under-water.
    • Sibling Onyx markets needed enough liquidity that the earlier borrow step could turn the synthetic collateral state into real extractable assets.
  • Technical anatomy worth remembering:
    • The exploit was not just the old Compound precision bug again. The precision bug created the self-liquidation setup, but the large extraction came from a second trust-boundary failure in the liquidation helper.
    • liquidateWithSingleRepay() was designed to repay one market, mint the leftover amount back into the same repay market, and return those oTokens to the borrower. That convenience path became the exploit primitive because the whole helper-owned authority object — which market defines debt, which underlying token should move, where extra repay is reminted, and what token is safe to send back to the borrower — was caller-supplied.
    • The code path matters: borrowBalanceCurrent() determined extraRepayAmount; underlying() chose the token whose transferFrom() / approve() semantics would be trusted; fake liquidateBorrow() and fake mint() let the helper keep advancing; and the final IERC20(oTokenRepay).transfer(...) handed control to the counterfeit repay token before the helper proved the borrow was actually cleared.
    • This is a strong EVM exemplar of interface-shaped trust failure: the fake repay token only had to look enough like an Onyx market to satisfy local calls. No canonical market identity was enforced.
    • The final malicious action did not happen in the obvious liquidation call. It was smuggled into the counterfeit repay token’s transfer() callback after Onyx had already accepted the fake market and fake accounting story. nonReentrant did not save the protocol because the attacker did not need to re-enter NFTLiquidation; they only needed the helper to bless a counterfeit endpoint and then let that endpoint call real Onyx markets.
    • The sharper positive-design lesson is not merely check the market address. A liquidation helper must keep canonical market identity, underlying-asset semantics, extra-repay meaning, and borrower / payout context fused as one protocol-owned object before any convenience repay/mint/return path runs. That is the helper-side lesson carried by helper-path invariant reuse and fail-closed discipline as much as by the counterfeit-root pattern itself.
    • Onyx’s second major exploit therefore preserved a different lesson than its 2023 oPEPE incident: repeating a known arithmetic family is bad, but layering an unpinned helper-integrity bug on top of it is worse.
  • Representative transactions and addresses:
    • Attack tx: 0x46567c731c4f4f7e27c4ce591f0aebdeb2d9ae1038237a0134de7b13e63d8729
    • Attacker EOA: 0x680910cf5fc9969a25fd57e7896a14ff1e55f36b
    • Main exploit contract: 0xa57eda20be51ae07df3c8b92494c974a92cf8956
    • oETH rate manipulator: 0xae7d68b140ed075e382e0a01d6c67ac675afa223
    • Fake repay oToken: 0x4f8b8c1b828147c1d6efc37c0326f4ac3e47d068
    • Fake underlying token: 0x3f100c9e9b9c575fe73461673f0770435575dc0e
    • Fake collateral oToken: 0xad45812c62fcbc8d54d0cc82773e85a11f19a248
    • Vulnerable NFTLiquidation implementation: 0xf10bc5be84640236c71173d1809038af4ee19002
    • NFTLiquidationProxy: 0x323398DE3C35F96053D930d25FE8d92132F83d44
    • oETH: 0x2CCb7d00a9E10D0c3408B5EEfb67011aBfaCb075
    • oVUSD: 0xeE894c051c402301bC19bE46c231D2a8E38b0451
    • VUSD: 0x0BFFDD787C83235f6F0afa0Faed42061a4619B7a
  • Response / mitigation:
    • Onyx publicly acknowledged unusual activity the same day, but the exploit was already complete before the statement.
    • The concrete fix direction was to bind liquidation helpers to Comptroller-listed markets and canonical underlying assets rather than letting callers supply interface-compatible substitutes.
    • More generally, any helper that composes repay, mint, seize, and token transfer flows must treat contract identity as part of the security boundary; looks like an oToken is not enough.
    • The incident also reinforced that Compound-family precision issues were still exploitable in production whenever markets could be pushed into pathological low-liquidity states.

Pattern tags

  • Counterfeit helper-root substitution: NFTLiquidation let the caller choose oTokenRepay and oTokenCollateral, then built the whole liquidation path on those attacker-supplied contracts instead of loading Comptroller-authenticated markets first.
  • Interface-shaped debt and payout fiction: the helper trusted borrowBalanceCurrent(), underlying(), liquidateBorrow(), mint(), and balanceOf() return values from a fake market that only needed to satisfy the expected interface shape, not the protocol’s real market registry.
  • Helper-owned authority-chain collapse: the decisive trusted object was not just which address is an oToken; it was the full borrow balance -> underlying token -> repay sink -> remint target -> final borrower transfer chain the counterfeit market was allowed to author inside one privileged liquidation wrapper.
  • Self-liquidation reward monetization: the fake helper path only became worth money because the attacker first pushed their own oETH position into shortfall, then used a tiny real oVUSD liquidation to seize economically large manipulated collateral.

Why the exploit worked

  • Onyx split one liquidation authority object into attacker-chosen pieces. The caller got to decide which contract defined outstanding debt, which underlying token would be pulled and approved, which market would receive repay and mint calls, and which token contract would own the final transfer callback to the borrower.
  • Canonical-market validation never arrived. NFTLiquidation checked the borrower’s shortfall through the Comptroller, but it did not ask the Comptroller whether the supplied repay and collateral markets were legitimate Onyx markets before trusting their methods and return values.
  • The fake repay market did not need to do much. Returning borrowBalanceCurrent(borrower) = 0 converted almost the whole user-chosen repayAmount into extraRepayAmount, while fake underlying(), liquidateBorrow(), and mint() responses let the helper keep advancing through a fictional repay-and-remint story.
  • The final control handoff mattered as much as the earlier fake reads. IERC20(oTokenRepay).transfer(borrower, IERC20(oTokenRepay).balanceOf(address(this))) let the counterfeit repay token execute arbitrary logic before the helper proved the real borrow was cleared and before extraRepayAmount was reset.
  • nonReentrant did not save the flow because the attacker did not need to re-enter NFTLiquidation itself. They only needed the helper to bless a fake market and then let that fake market call real Onyx contracts from the helper’s final token-return surface.
  • The precision-manipulated oETH state was the profit amplifier, not the counterfeit-root bug itself. The liquidation helper turned a self-created shortfall into extractable value because it never kept canonical market identity, real underlying semantics, and payout meaning fused.

Detection and audit pivots

  • In every liquidation, deleverage, repay-and-remint, or foreclosure helper, ask whether the protocol loads canonical markets from the Comptroller / registry first or instead trusts caller-supplied market addresses to define debt, underlying token, seize target, and payout token meaning.
  • Trace the whole helper-owned authority chain, not only the first market check: which contract defines borrowBalanceCurrent(), which token address is approved, which market receives liquidateBorrow() and mint(), and which token contract controls the final transfer() / callback surface?
  • Treat interface compatibility as a red flag in privileged helper flows. A contract that exposes underlying(), liquidateBorrow(), or mint() is not yet a legitimate market; the sharper question is whether the protocol itself named that exact contract as canonical for this liquidation path.
  • Review extra repay, refund, and remint leftover branches as first-class authority surfaces. If the branch is computed from attacker-influenced debt reads, fake debt can become real payout inventory inside the helper.
  • Rehearse the exploit with a mixed graph: real Comptroller shortfall, real seized collateral market, fake repay market, fake underlying token, and a malicious token-return callback that never re-enters the helper. If that still cashes out, the trust boundary is helper-root authenticity, not classical reentrancy alone.
  • For Compound-family forks, separate can a low-liquidity market be forced into pathological accounting? from which helper then monetizes that state? Onyx failed at both layers at once.

Failure-prevention checklist

  • Load repay and collateral markets from a protocol-owned registry or Comptroller-authenticated allowlist before any borrow-balance, underlying-token, repay, mint, or transfer logic runs.
  • Treat the whole liquidation convenience path as one authority object: canonical repay market, canonical collateral market, canonical underlying token, allowed leftover-remint target, and final payout destination should be fixed together rather than rediscovered from attacker-controlled contracts.
  • Never let a caller-supplied market both define debt / payout meaning and own the helper’s final callback-capable token transfer surface.
  • Prove real debt reduction before any leftover value is reminted, returned, or exposed to borrower-controlled callbacks.
  • Make leftover-repay branches fail closed. If debt reads, mint results, or repayment semantics come from an untrusted or mismatched market, the helper should stop before any asset or tokenized claim is returned.
  • Reuse canonical liquidation state machines instead of helper-local reimplementations that independently read debt, approve tokens, remint leftovers, and return wrapper tokens.
  • Fuzz mixed fake/real-market paths directly: fake repay market + fake underlying + real collateral seize + final token-transfer callback into a real market.
  • For low-liquidity Compound-family markets, pair market-seeding and thin-state controls with helper hardening; otherwise one market-accounting bug can still be cash-settled by an unrelated convenience wrapper.

Audit prompts

  • Does the helper ever ask the Comptroller or another protocol-owned registry whether oTokenRepay and oTokenCollateral are the exact markets this path is allowed to trust?
  • Which contract defines each step of the helper-owned authority chain: outstanding debt, underlying token identity, repay success, leftover remint success, and final borrower payout?
  • Could a counterfeit market return borrowBalanceCurrent() = 0 and thereby turn most of repayAmount into helper-controlled extraRepayAmount without moving any real value?
  • Before the helper returns any token or leftover value, what exact proof shows that the borrower’s real debt in the canonical market actually reached zero?
  • Does any final transfer() or token-return step hand control to attacker-chosen code while helper-local accounting variables like extraRepayAmount still imply unfinished privileged work?
  • If the helper never re-enters itself, can a fake token or fake market still call real protocol markets and monetize the helper’s intermediate state? If yes, the boundary failure is broader than add nonReentrant.
  • Are liquidation convenience wrappers reusing the protocol’s audited core repay/seize path, or are they rebuilding it with fresh caller-supplied market and token assumptions?
  • If a manipulated low-liquidity market creates shortfall, which helper or liquidation route is next in line to turn that paper shortfall into real reserve outflow?

Lessons that should already be standard

  • Looks like an oToken is not a trust proof. In privileged helpers, interface shape without canonical market identity is just counterfeit infrastructure.
  • Liquidation helpers are not neutral plumbing. If they read debt, pull tokens, mint leftovers, or return claims, they are part of the live solvency and payout boundary.
  • Borrow-balance reads, leftover-remint meaning, and final token-return callbacks belong to one protocol-owned authority object. If callers can choose each piece independently, they can compose fiction into payout.
  • A final borrower-facing token transfer is still a privileged execution edge. If the token contract is attacker-chosen, the last callback can be the real exploit surface even when earlier calls already looked suspicious.
  • nonReentrant only protects the frame it guards. It does not make counterfeit endpoints safe to trust or stop those endpoints from calling real protocol markets elsewhere.
  • Repeated market-accounting bugs become much worse when helpers can cash-settle them through separate trust-boundary failures. Arithmetic weakness and counterfeit-root acceptance compose.
  • Healthy design pins canonical market identity before convenience logic begins, and only then reasons about debt, leftovers, and payout semantics.

Why this incident matters

  • It preserves an unusually clear two-stage exploit: first manufacture a profitable liquidation target out of your own position, then use a counterfeit integration endpoint to turn the protocol’s liquidation helper into the payout path.
  • It is a useful counterexample to the idea that only explicit upgrade or arbitrary-call bugs create fake-contract risk on EVM. Here, an ordinary convenience helper accepted counterfeit market contracts and reasoned over their return values as if they were canonical protocol state.
  • It also shows how repeat incidents in one protocol can rhyme without being identical: Onyx’s 2023 exploit was a fresh-market empty-supply replay, while the 2024 exploit combined precision manipulation with liquidation-helper root-of-trust failure.

Internal linkages

Control surface