This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef6350x7d61ed92a6778f5abf5c94085739f1edabec2800Ethereum0x5fdbcd61bc9bd4b6d3fd1f49a5d253165ea11750Ethereum0x714bd93ab6ab2f0bcfd2aeaf46a46719991d0d79Ethereum0x8f35113cfaba700ed7a907d92b114b44421e412aEthereum0xbced4e924f28f43a24ceedec69ee21ed4d04d2ddEthereum0x0c19d213e9f2a5cbaa4ec6e8eac55a22276b0641Ethereum0x830dacd5d0a62afa92c9bc6878461e9cd317b085EthereumOn 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.
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 . In the affected deployment, oPEPE was not an isolated market: at block it was listed with a collateral factor of , so any overstatement in oPEPE's exchange rate translated directly into extra borrow capacity.
0x1933f1183c421d44d531ed40a5d2445f6a91646d0xfee4428b7f403499c50a6da947916b71d33142dcEthereumgetAccountSnapshot()184767690.5e18The 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:
Those conditions made the exploit permissionless and deterministic from public state.
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.
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:
totalSupply was only 2 units.totalCash without increasing supply.getAccountSnapshot() returned the attacker's dust oPEPE balance together with an enormous exchange rate.borrowAllowed() accepted that collateral and allowed real assets to be borrowed from other markets.The ACT conditions were also present and sufficient:
The attacker used one EOA, one main orchestrator, and seven borrow helpers:
0x085bdff2c522e8637d4154039db8746bb8642bff0x526e8e98356194b64eae4c2d443cc8aad367336f0xf8e15371832aed6cd2741c572b961ffeaf751eaa0xdb9be000d428bf3b3ae35f604a0d7ab938bea6eb0xe495cb62b36cbe40b9ca90de3dc5cdf0a4259e1c0x414764af57c43e36d7e0c3e55ebe88f410a6edb60xcede81bb4046587dad6fc3606428a0eb4084d7600xe82f9ffe18fe511d31320d73c2e6be4338575d220xf79cae9a064f4d6395e293fd7162856ffeeb7613The 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:
0x714bd93ab6ab2f0bcfd2aeaf46a46719991d0d79: 334476442580295733160 wei0x8f35113cfaba700ed7a907d92b114b44421e412a: 513987927004 USDC units0xbced4e924f28f43a24ceedec69ee21ed4d04d2dd: 249534202651 USDT units0x0c19d213e9f2a5cbaa4ec6e8eac55a22276b0641: 81375414746413246657 PAXG wei0x830dacd5d0a62afa92c9bc6878461e9cd317b085: 103657601740278955029570 wei0x1933f1183c421d44d531ed40a5d2445f6a91646d: 1312889485 satoshis0xfee4428b7f403499c50a6da947916b71d33142dc: 10082867210431152021425 weiFinally, 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.
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.
| Asset | Raw Loss | Decimals |
|---|---|---|
| ETH | 334476442580295733160 | 18 |
| USDC | 513987927004 | 6 |
| USDT | 249534202651 | 6 |
| PAXG | 81375414746413246657 | 18 |
| DAI | 103657601740278955029570 | 18 |
| WBTC | 1312889485 | 8 |
| LINK | 10082867210431152021425 | 18 |
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.
0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef63518476768artifacts/collector/seed/1/0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635/trace.cast.logartifacts/collector/seed/1/0xf7c21600452939a81b599017ee24ee0dfd92aaaccd0a55d02819a7658a6ef635/balance_diff.json0x5fdbcd61bc9bd4b6d3fd1f49a5d253165ea11750artifacts/collector/seed/1/0x9dcb6bc351ab416f35aeab1351776e2ad295abc4/src/Contract.sol0x7d61ed92a6778f5abf5c94085739f1edabec28000x4345d308f02d1beb92475bda25e7c62be288478e0x2a68c871aaaf80e6d41114e92bc4c313f0b94bb8