All incidents

Onyx oPEPE Donation Overvaluation

Share
Nov 01, 2023 09:58 UTCAttackLoss: 334.48 ETH, 513,987.93 USDC +5 morePending manual check1 exploit txWindow: Atomic
Estimated Impact
334.48 ETH, 513,987.93 USDC +5 more
Label
Attack
Exploit Tx
1
Addresses
9
Attack Window
Atomic
Nov 01, 2023 09:58 UTC → Nov 01, 2023 09:58 UTC

Exploit Transactions

TX 1Ethereum
0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635
Nov 01, 2023 09:58 UTCExplorer

Victim Addresses

0x7d61ed92a6778f5abf5c94085739f1edabec2800Ethereum
0x5fdbcd61bc9bd4b6d3fd1f49a5d253165ea11750Ethereum
0x714bd93ab6ab2f0bcfd2aeaf46a46719991d0d79Ethereum
0x8f35113cfaba700ed7a907d92b114b44421e412aEthereum
0xbced4e924f28f43a24ceedec69ee21ed4d04d2ddEthereum
0x0c19d213e9f2a5cbaa4ec6e8eac55a22276b0641Ethereum
0x830dacd5d0a62afa92c9bc6878461e9cd317b085Ethereum
0x1933f1183c421d44d531ed40a5d2445f6a91646dEthereum
0xfee4428b7f403499c50a6da947916b71d33142dcEthereum

Loss Breakdown

334.48ETH
513,987.93USDC
249,534.2USDT
81.38PAXG
103,657.6DAI
13.13WBTC
10,082.87LINK

Similar Incidents

Root Cause Analysis

Onyx oPEPE Donation Overvaluation

1. Incident Overview TL;DR

On Ethereum mainnet transaction 0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635 in block 18476769, EOA 0x085bdff2c522e8637d4154039db8746bb8642bff called attacker contract 0x526e8e98356194b64eae4c2d443cc8aad367336f and executed a single-transaction drain against Onyx Protocol. The attack flash-borrowed 4,000 WETH from Aave V3, swapped that WETH into 2520870348093423681390050791472 PEPE, then used PEPE to turn the collateral-enabled oPEPE market (0x5fdbcd61bc9bd4b6d3fd1f49a5d253165ea11750) into an empty-market donation oracle.

The root cause is that Onyx trusted the oPEPE exchange rate as a collateral input even though any user could increase totalCash by transferring PEPE directly into the market contract without minting offsetting oPEPE. After the attacker redeemed oPEPE supply down to dust, the donated PEPE made each remaining oPEPE unit appear astronomically valuable. Comptroller (0x7d61ed92a6778f5abf5c94085739f1edabec2800) then treated that synthetic value as honest collateral and authorized cross-market borrows from oETH, oUSDC, oUSDT, oPAXG, oDAI, oBTC, and oLINK. The sender EOA finished the transaction with a net native-balance gain of 1156561128538858638915 wei, or 1156.561128538858638915 ETH, already net of gas.

2. Key Background

Onyx uses Compound-style OToken accounting. Each OToken's stored exchange rate is derived from market cash, total borrows, total reserves, and total supply. That exchange rate is then exposed to Comptroller liquidity checks through getAccountSnapshot(). In the affected deployment, oPEPE was not an isolated market: at block 18476769 it was listed with a collateral factor of 0.5e18, so any overstatement in oPEPE's exchange rate translated directly into extra borrow capacity.

The pricing leg was not the bug. The seed trace shows Comptroller querying ChainlinkOracle for both oPEPE and the borrowed markets, and the PEPE price comes from the configured oracle path rather than a manipulable spot read from the same Uniswap V2 pool used by the attacker. The exploit therefore does not depend on oracle tampering; it depends on internal collateral accounting.

The relevant pre-state is Ethereum mainnet immediately before the transaction was executed, at block 18476768. At that point:

  • oPEPE was listed and collateral-enabled.
  • oETH and six other Onyx markets held borrowable liquidity.
  • Direct ERC-20 transfers into the oPEPE contract were possible.
  • Aave V3 flash loans and Uniswap V2 swaps were permissionless.

Those conditions made the exploit permissionless and deterministic from public state.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK, specifically an empty-market donation overvaluation attack against a collateral-enabled OToken listing. The core invariant is simple: collateral value should rise only when underlying is added in exchange for proportional OToken shares, not when a user donates underlying directly into the market contract. Onyx violated that invariant because exchangeRateStoredInternal() treated donated PEPE the same as minted PEPE and did not defend against a near-zero totalSupply.

Once the attacker became the dominant oPEPE supplier, redeeming supply down to dust let the attacker control the denominator of the exchange-rate formula. A direct PEPE donation then controlled the numerator. Comptroller's borrow checks consumed the resulting inflated exchange rate through getAccountSnapshot() and getHypotheticalAccountLiquidityInternal(), so the attacker could present a dust oPEPE position as collateral worth nearly the entire donated PEPE inventory. Because other Onyx markets still had real cash, the attacker could immediately borrow those assets out. No privileged role, stolen key, or attacker-only access-controlled contract was required.

4. Detailed Root Cause Analysis

The oPEPE market implementation makes the exchange-rate bug explicit:

function getAccountSnapshot(address account) external view returns (uint, uint, uint, uint) {
    uint oTokenBalance = accountTokens[account];
    uint borrowBalance;
    uint exchangeRateMantissa;

    (mErr, borrowBalance) = borrowBalanceStoredInternal(account);
    (mErr, exchangeRateMantissa) = exchangeRateStoredInternal();
    return (uint(Error.NO_ERROR), oTokenBalance, borrowBalance, exchangeRateMantissa);
}

function exchangeRateStoredInternal() internal view returns (MathError, uint) {
    uint _totalSupply = totalSupply;
    if (_totalSupply == 0) {
        return (MathError.NO_ERROR, initialExchangeRateMantissa);
    } else {
        uint totalCash = getCashPrior();
        (mathErr, cashPlusBorrowsMinusReserves) = addThenSubUInt(totalCash, totalBorrows, totalReserves);
        (mathErr, exchangeRate) = getExp(cashPlusBorrowsMinusReserves, _totalSupply);
        return (MathError.NO_ERROR, exchangeRate.mantissa);
    }
}

That value is then consumed by Comptroller's liquidity engine:

function borrowAllowed(address oToken, address borrower, uint borrowAmount) external returns (uint) {
    if (oracle.getUnderlyingPrice(OToken(oToken)) == 0) {
        return uint(Error.PRICE_ERROR);
    }

    (Error err, , uint shortfall) =
        getHypotheticalAccountLiquidityInternal(borrower, OToken(oToken), 0, borrowAmount);
    if (err != Error.NO_ERROR) return uint(err);
    if (shortfall > 0) return uint(Error.INSUFFICIENT_LIQUIDITY);
    return uint(Error.NO_ERROR);
}

function getHypotheticalAccountLiquidityInternal(
    address account,
    OToken oTokenModify,
    uint redeemTokens,
    uint borrowAmount
) internal view returns (Error, uint, uint) {
    (oErr, vars.oTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) =
        asset.getAccountSnapshot(account);
    vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa});
    vars.exchangeRate = Exp({mantissa: vars.exchangeRateMantissa});
    vars.oraclePrice = Exp({mantissa: oracle.getUnderlyingPrice(asset)});
    vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
    vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.oTokenBalance, vars.sumCollateral);
}

The exploit path visible in the seed trace matches that code exactly:

OErc20Delegator::redeem(4999999999999999999999999998)
...
storage change: totalSupply ... -> 2
PepeToken::transfer(OErc20Delegator[oPEPE], 2520870348093423681390050791471)
...
OErc20Delegate::getAccountSnapshot(attackerHelper) -> (0, 2, 0, 1260435174046711840695025395736000000000000000000)
Comptroller::borrowAllowed(OEther, attackerHelper, 334476442580295733160)

That trace proves the concrete breakpoint:

  1. The attacker minted oPEPE from a tiny seed amount.
  2. The attacker redeemed almost the full position until totalSupply was only 2 units.
  3. The attacker transferred PEPE directly into oPEPE, increasing totalCash without increasing supply.
  4. getAccountSnapshot() returned the attacker's dust oPEPE balance together with an enormous exchange rate.
  5. borrowAllowed() accepted that collateral and allowed real assets to be borrowed from other markets.

The ACT conditions were also present and sufficient:

  • The attacker could become the dominant supplier in the empty market.
  • oPEPE had a non-zero collateral factor.
  • Direct token donations into the market contract were permissionless.
  • Other Onyx markets held enough cash to make the inflated collateral immediately monetizable.

5. Adversary Flow Analysis

The attacker used one EOA, one main orchestrator, and seven borrow helpers:

  • Sender EOA: 0x085bdff2c522e8637d4154039db8746bb8642bff
  • Main attack contract: 0x526e8e98356194b64eae4c2d443cc8aad367336f
  • Helper for oETH: 0xf8e15371832aed6cd2741c572b961ffeaf751eaa
  • Helper for oUSDC: 0xdb9be000d428bf3b3ae35f604a0d7ab938bea6eb
  • Helper for oUSDT: 0xe495cb62b36cbe40b9ca90de3dc5cdf0a4259e1c
  • Helper for oPAXG: 0x414764af57c43e36d7e0c3e55ebe88f410a6edb6
  • Helper for oDAI: 0xcede81bb4046587dad6fc3606428a0eb4084d760
  • Helper for oBTC: 0xe82f9ffe18fe511d31320d73c2e6be4338575d22
  • Helper for oLINK: 0xf79cae9a064f4d6395e293fd7162856ffeeb7613

The transaction flowed in three stages.

First, the main contract borrowed 4,000 WETH from Aave V3 and swapped it into 2520870348093423681390050791472 PEPE on Uniswap V2. This PEPE inventory was the fuel for the empty-market donation attack.

Second, the main contract distributed PEPE into helpers. Each helper entered oPEPE as collateral, paired it with one borrow market, and used the inflated collateral value to extract real liquidity. The seed trace shows the following successful borrows:

  • oETH 0x714bd93ab6ab2f0bcfd2aeaf46a46719991d0d79: 334476442580295733160 wei
  • oUSDC 0x8f35113cfaba700ed7a907d92b114b44421e412a: 513987927004 USDC units
  • oUSDT 0xbced4e924f28f43a24ceedec69ee21ed4d04d2dd: 249534202651 USDT units
  • oPAXG 0x0c19d213e9f2a5cbaa4ec6e8eac55a22276b0641: 81375414746413246657 PAXG wei
  • oDAI 0x830dacd5d0a62afa92c9bc6878461e9cd317b085: 103657601740278955029570 wei
  • oBTC 0x1933f1183c421d44d531ed40a5d2445f6a91646d: 1312889485 satoshis
  • oLINK 0xfee4428b7f403499c50a6da947916b71d33142dc: 10082867210431152021425 wei

Finally, the main contract consolidated the borrowed assets, swapped them back toward WETH and ETH, repaid 4,002 WETH to Aave V3, and returned the net profit to the sender EOA. The seed balance diff records the sender's native balance increasing from 4731023480721325444 wei to 1161292152019579964359 wei, a net delta of 1156561128538858638915 wei after gas.

6. Impact & Losses

The exploit drained seven Onyx borrow markets in one transaction and demonstrated that any similarly configured empty collateral market could be turned into a cross-market drain.

AssetRaw LossDecimals
ETH33447644258029573316018
USDC5139879270046
USDT2495342026516
PAXG8137541474641324665718
DAI10365760174027895502957018
WBTC13128894858
LINK1008286721043115202142518

The sender EOA paid approximately 0.373617125 ETH in transaction fees and still realized 1156.561128538858638915 ETH in net profit. Because the exploit path is fully permissionless and completes in one transaction, this was a realizable ACT opportunity rather than an unreproducible edge case.

7. References

  • Seed transaction: 0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635
  • Pre-state anchor: Ethereum mainnet block 18476768
  • Main attack path trace: artifacts/collector/seed/1/0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635/trace.cast.log
  • Profit and loss deltas: artifacts/collector/seed/1/0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635/balance_diff.json
  • oPEPE market: 0x5fdbcd61bc9bd4b6d3fd1f49a5d253165ea11750
  • OErc20 implementation artifact: artifacts/collector/seed/1/0x9dcb6bc351ab416f35aeab1351776e2ad295abc4/src/Contract.sol
  • Comptroller proxy: 0x7d61ed92a6778f5abf5c94085739f1edabec2800
  • Comptroller implementation: 0x4345d308f02d1beb92475bda25e7c62be288478e
  • Oracle contract: 0x2a68c871aaaf80e6d41114e92bc4c313f0b94bb8